mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-16 05:54:01 +03:00
Compare commits
876 Commits
v0.45.0
...
actors/con
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
669834d2d3 | ||
|
|
2b11164532 | ||
|
|
1d2f991be5 | ||
|
|
eac837fc22 | ||
|
|
525946e94e | ||
|
|
bb03660bd7 | ||
|
|
9714296efb | ||
|
|
a3f27ec2ec | ||
|
|
316cf47ceb | ||
|
|
ef9ed8de32 | ||
|
|
217bf75b7c | ||
|
|
77398a558d | ||
|
|
187cd8ddcd | ||
|
|
d9ead47d19 | ||
|
|
7348bdac50 | ||
|
|
0f6ff76165 | ||
|
|
e4d0cbefbe | ||
|
|
69a6e22c7c | ||
|
|
cfda5f693b | ||
|
|
ff8e34cccf | ||
|
|
ff5e565eeb | ||
|
|
20254eea7c | ||
|
|
f62d476c56 | ||
|
|
119eecefc3 | ||
|
|
59a6868ecd | ||
|
|
2910c517bb | ||
|
|
da3677f33d | ||
|
|
968d07229e | ||
|
|
28a553f63b | ||
|
|
b2711ced99 | ||
|
|
6734585712 | ||
|
|
c85a4e63da | ||
|
|
ce7d3a7e1d | ||
|
|
a5f2192d67 | ||
|
|
190914fd72 | ||
|
|
25dcad757a | ||
|
|
c72da58eaf | ||
|
|
f3b6ceb876 | ||
|
|
fa2d08cb6d | ||
|
|
2f55b7cbc3 | ||
|
|
8035a4c8d5 | ||
|
|
7667377c98 | ||
|
|
5e2cc6f40e | ||
|
|
e8cb0c831c | ||
|
|
337fc6fd1e | ||
|
|
590feadab9 | ||
|
|
9a94aa0694 | ||
|
|
34e9a6d198 | ||
|
|
6120bb4dd8 | ||
|
|
09d196f7c5 | ||
|
|
0b2706a488 | ||
|
|
eefd51cc4d | ||
|
|
f68ad42618 | ||
|
|
0d2624db9c | ||
|
|
5c988a921a | ||
|
|
9607495ab4 | ||
|
|
d359438814 | ||
|
|
931a6c2adb | ||
|
|
8564d35682 | ||
|
|
ecad31cae2 | ||
|
|
58fcf7cbe6 | ||
|
|
96ea55b9bc | ||
|
|
9fce987106 | ||
|
|
97bd466f38 | ||
|
|
1b3ba3f6c2 | ||
|
|
254ae88cda | ||
|
|
cb665681c4 | ||
|
|
d467855870 | ||
|
|
16b5c16a66 | ||
|
|
2ed703540d | ||
|
|
cf54786cc3 | ||
|
|
cdc23744ae | ||
|
|
4b957a0106 | ||
|
|
49305ed1f9 | ||
|
|
99ab38dc79 | ||
|
|
35dcf58ee9 | ||
|
|
d0243fe396 | ||
|
|
59fd6b3726 | ||
|
|
7eaaea717b | ||
|
|
e538ea0bf1 | ||
|
|
8689ddd72b | ||
|
|
519cda0eef | ||
|
|
cf9a31986f | ||
|
|
8158452b37 | ||
|
|
dbbe2a403a | ||
|
|
80509c83ee | ||
|
|
2e77d57282 | ||
|
|
f673a91a67 | ||
|
|
35987b9c8d | ||
|
|
49cb131b7d | ||
|
|
84678c3a7b | ||
|
|
24c85029b4 | ||
|
|
effd2a146f | ||
|
|
c0d96a0b13 | ||
|
|
8a75774971 | ||
|
|
73bda4580a | ||
|
|
104ddb1120 | ||
|
|
d198ded1dd | ||
|
|
2726ddd0c2 | ||
|
|
89b4abb8b5 | ||
|
|
9d4b621caa | ||
|
|
6c1d45e021 | ||
|
|
83b45f682d | ||
|
|
3d9d4de619 | ||
|
|
212c574a68 | ||
|
|
4f97cd4188 | ||
|
|
68a5abdcca | ||
|
|
2eada149af | ||
|
|
81e5aa2168 | ||
|
|
e88ff03ea2 | ||
|
|
fc42f6211d | ||
|
|
bf9f76bbd5 | ||
|
|
fea933b62d | ||
|
|
cdc77062f0 | ||
|
|
7027296632 | ||
|
|
e046a03364 | ||
|
|
0616abaf7f | ||
|
|
9ad2bfc7b3 | ||
|
|
bc4f75c44f | ||
|
|
a71219637a | ||
|
|
b3d40a476b | ||
|
|
272d1479bd | ||
|
|
f13424720b | ||
|
|
6aefe6c8e1 | ||
|
|
dd77bb0f32 | ||
|
|
0c95b7c3cc | ||
|
|
f4a20e3ef9 | ||
|
|
5db570bf66 | ||
|
|
e8c398ffbd | ||
|
|
9dd3d319cf | ||
|
|
b9fdd530c0 | ||
|
|
cc9163f577 | ||
|
|
b27a52a591 | ||
|
|
bb5a111e3d | ||
|
|
8ad95b8af1 | ||
|
|
e56dc124ce | ||
|
|
91c2ea2a66 | ||
|
|
ce6bdb9d7e | ||
|
|
8b22fcaaa4 | ||
|
|
4abd1a5124 | ||
|
|
714b5b3307 | ||
|
|
7aacbd0245 | ||
|
|
532a7a1d14 | ||
|
|
9399cba775 | ||
|
|
5c7e5f680b | ||
|
|
4e8995e04e | ||
|
|
da3489cfa5 | ||
|
|
12ac7973a2 | ||
|
|
27945f5695 | ||
|
|
2340bb0f1d | ||
|
|
d860ef43ec | ||
|
|
d5cf60689e | ||
|
|
f4706cd6cc | ||
|
|
e33a9b8c88 | ||
|
|
20df033c1f | ||
|
|
f9f93f0ef1 | ||
|
|
64d4ac7919 | ||
|
|
0ed2c48591 | ||
|
|
5559077ab4 | ||
|
|
c8ff6b1cca | ||
|
|
ea5a0fcf1e | ||
|
|
a9fdab10f1 | ||
|
|
605c8b2ba4 | ||
|
|
c96d591484 | ||
|
|
9834a08843 | ||
|
|
4f40352497 | ||
|
|
975d3c79c6 | ||
|
|
2f0338211d | ||
|
|
9c1a8b3c87 | ||
|
|
ce4b89da65 | ||
|
|
0d40bf29ab | ||
|
|
ff26c4273a | ||
|
|
444f7ff2b0 | ||
|
|
a0cb574313 | ||
|
|
978ac87def | ||
|
|
59fe481af9 | ||
|
|
4f09de2cfc | ||
|
|
8f48d10d55 | ||
|
|
5c4d7ff877 | ||
|
|
c5a4ffa523 | ||
|
|
6449b28b24 | ||
|
|
90fbfd7038 | ||
|
|
9ec4683d18 | ||
|
|
027e6f078d | ||
|
|
33cceeafa8 | ||
|
|
544f4e63d8 | ||
|
|
86152d996c | ||
|
|
fbdb93f1a7 | ||
|
|
f8f7222468 | ||
|
|
f79a86cded | ||
|
|
626adbf14c | ||
|
|
2b94535ade | ||
|
|
e825a58b39 | ||
|
|
b85a81f9b1 | ||
|
|
a06d9d21e4 | ||
|
|
cbecbd088f | ||
|
|
3c42a713ce | ||
|
|
e7e0176316 | ||
|
|
0551502586 | ||
|
|
5251cd9799 | ||
|
|
673e5841aa | ||
|
|
dc6558522e | ||
|
|
724c41cb7a | ||
|
|
fc52bd4efb | ||
|
|
2701754e73 | ||
|
|
3700bd942d | ||
|
|
2b5401dd2f | ||
|
|
95e9af4bbf | ||
|
|
0c039cc88c | ||
|
|
192f71a13c | ||
|
|
f90f21b620 | ||
|
|
d71966f996 | ||
|
|
cad80e7d74 | ||
|
|
832b7fbff4 | ||
|
|
e3905cf315 | ||
|
|
b59e35cb59 | ||
|
|
a675d075d1 | ||
|
|
2f56d16f98 | ||
|
|
f4eb8db509 | ||
|
|
e7bd66d1a7 | ||
|
|
1d06817065 | ||
|
|
34890235ba | ||
|
|
27ca713cd4 | ||
|
|
e0fc4cc01b | ||
|
|
01141b8c0f | ||
|
|
8fecc22888 | ||
|
|
d7f19ad5e5 | ||
|
|
45bcdb4b1d | ||
|
|
89349dc1ad | ||
|
|
6159e0bf96 | ||
|
|
4d22c2dbcf | ||
|
|
590e495c1d | ||
|
|
3a339e3319 | ||
|
|
d98db79d5e | ||
|
|
7d2a9b3345 | ||
|
|
375f0a0c34 | ||
|
|
522a4eee00 | ||
|
|
0c41872cd4 | ||
|
|
b3fe143136 | ||
|
|
a90c8abe51 | ||
|
|
ae269371da | ||
|
|
27caaf2cac | ||
|
|
0678699d0c | ||
|
|
4f82957e13 | ||
|
|
5e2534bd6b | ||
|
|
eb0456a395 | ||
|
|
f0249377ac | ||
|
|
97f2e4eec2 | ||
|
|
adbdfcf9ff | ||
|
|
94a48977ba | ||
|
|
5ab0996475 | ||
|
|
60825c5743 | ||
|
|
310b81c277 | ||
|
|
549b487663 | ||
|
|
6eebecd85a | ||
|
|
1de231f1ff | ||
|
|
aab7e364e8 | ||
|
|
56afebc242 | ||
|
|
bb7b0d5608 | ||
|
|
80558c5625 | ||
|
|
a5629e63d2 | ||
|
|
317879bb37 | ||
|
|
c62533d388 | ||
|
|
0978a89fcc | ||
|
|
53ef61a3e5 | ||
|
|
4811625cdd | ||
|
|
9dbc02e568 | ||
|
|
43a1e4a25b | ||
|
|
e78b16d0c6 | ||
|
|
1967de6828 | ||
|
|
2c64bebf6a | ||
|
|
2a11c3e609 | ||
|
|
a0ced2443c | ||
|
|
746aa71d67 | ||
|
|
9ca3d33c88 | ||
|
|
4df4bcb645 | ||
|
|
875c5b94a6 | ||
|
|
0e2cdc393e | ||
|
|
1e7442f5df | ||
|
|
e955118a6f | ||
|
|
811e8772b6 | ||
|
|
0a94f0fd64 | ||
|
|
03f9be0d12 | ||
|
|
2f25861d15 | ||
|
|
2af70d9b4d | ||
|
|
5828fa5779 | ||
|
|
1a032a812e | ||
|
|
8c68b08c12 | ||
|
|
646f849441 | ||
|
|
20bbd4a06f | ||
|
|
2d7e2ec8df | ||
|
|
72009ced67 | ||
|
|
4881130ead | ||
|
|
d6a7b503ff | ||
|
|
3c3916536e | ||
|
|
a24b2afb7b | ||
|
|
7c34501055 | ||
|
|
ba00f40bd4 | ||
|
|
2f651adf3b | ||
|
|
f42ba3bbef | ||
|
|
2341da99e9 | ||
|
|
2cce200892 | ||
|
|
cd2e9f3a2a | ||
|
|
f5e2c68ba3 | ||
|
|
651b58aee6 | ||
|
|
ffb2ef91bd | ||
|
|
4776b70d96 | ||
|
|
579cfdc678 | ||
|
|
e4a8ca476c | ||
|
|
386add08c4 | ||
|
|
894eaf3cff | ||
|
|
d9e7bf9eef | ||
|
|
b19d901618 | ||
|
|
0b625a9707 | ||
|
|
e60b80632f | ||
|
|
078152d4db | ||
|
|
ba2f0f18f4 | ||
|
|
3420a00073 | ||
|
|
f0144584af | ||
|
|
e1c5021eee | ||
|
|
c0e490c28f | ||
|
|
3c98c98fe3 | ||
|
|
1bc9f5f7e7 | ||
|
|
461293ba1d | ||
|
|
7c5ffbf9a5 | ||
|
|
f75cef83d5 | ||
|
|
e358c433f0 | ||
|
|
08e4ffeb60 | ||
|
|
59ca6b26ac | ||
|
|
f5da11b99b | ||
|
|
3eaf36aae7 | ||
|
|
0a6ff6f84b | ||
|
|
edb32d82b2 | ||
|
|
90f555f7c1 | ||
|
|
177ada10ba | ||
|
|
91b0d74c43 | ||
|
|
3a1dd3168e | ||
|
|
25f67bd25a | ||
|
|
e3483a9c78 | ||
|
|
95d49256f6 | ||
|
|
8cddcb88e8 | ||
|
|
a25d6ef56c | ||
|
|
14c7471b52 | ||
|
|
5d6a7fdb58 | ||
|
|
a1cd3251cd | ||
|
|
4eeb06f29d | ||
|
|
b2c718d13d | ||
|
|
8d30346f64 | ||
|
|
714b7744f0 | ||
|
|
d98c0a391a | ||
|
|
4fe56a8d5c | ||
|
|
cfc9e464d9 | ||
|
|
3d46badb3c | ||
|
|
f523f39483 | ||
|
|
4bde271b47 | ||
|
|
a3c968758a | ||
|
|
ca888b3dd2 | ||
|
|
ce88686c5f | ||
|
|
a9b6635126 | ||
|
|
e817f042ec | ||
|
|
c56afe016e | ||
|
|
a54b867105 | ||
|
|
29a1d3b778 | ||
|
|
12125713a2 | ||
|
|
ab9c0f9ac0 | ||
|
|
42b872d6b2 | ||
|
|
bfd71d090c | ||
|
|
d5e0cfd4a6 | ||
|
|
9981304b4b | ||
|
|
5cf73e9309 | ||
|
|
f125cf0dad | ||
|
|
6a038fcf9a | ||
|
|
76e0192cee | ||
|
|
3ebf94dd84 | ||
|
|
7ec57437ac | ||
|
|
ed2c7b2303 | ||
|
|
e03270eb9d | ||
|
|
d683d18d91 | ||
|
|
f184120890 | ||
|
|
04d8500910 | ||
|
|
93639dddb2 | ||
|
|
a190529117 | ||
|
|
73392b5837 | ||
|
|
65616f65e5 | ||
|
|
98a99fbb0a | ||
|
|
3f3b6b88fd | ||
|
|
8f98d8c0b4 | ||
|
|
c9308472a9 | ||
|
|
6362ff9861 | ||
|
|
10d640385f | ||
|
|
47927d1574 | ||
|
|
b356cef766 | ||
|
|
9fc45930a8 | ||
|
|
028d1c858e | ||
|
|
eb3963d0fc | ||
|
|
35d913f905 | ||
|
|
32485f4c7c | ||
|
|
ceb38b0825 | ||
|
|
c0b6ede5be | ||
|
|
c20e93b55c | ||
|
|
24ca6a106d | ||
|
|
9f0aa55be6 | ||
|
|
068fcc65a6 | ||
|
|
f2dfb3da5d | ||
|
|
cbf0e3117d | ||
|
|
694f266dea | ||
|
|
29fc185376 | ||
|
|
781be37416 | ||
|
|
b1f97e05a1 | ||
|
|
2c74865173 | ||
|
|
ad8a90c839 | ||
|
|
f9839a978c | ||
|
|
b81de45166 | ||
|
|
22f4254932 | ||
|
|
507f9490fa | ||
|
|
043cce615d | ||
|
|
69e2083722 | ||
|
|
d47b20326f | ||
|
|
fc9939d1f1 | ||
|
|
2c1c67b5e4 | ||
|
|
d010be4c88 | ||
|
|
01db8c0a46 | ||
|
|
fe5917d96d | ||
|
|
4f0b434c54 | ||
|
|
6bdf5fa37a | ||
|
|
47bd5ba1ba | ||
|
|
b746ac0835 | ||
|
|
79989fb176 | ||
|
|
ecc7e224e9 | ||
|
|
549d219f44 | ||
|
|
ffe18db2fb | ||
|
|
e8b172f1c3 | ||
|
|
097bda349a | ||
|
|
6e24517197 | ||
|
|
a3da943aa6 | ||
|
|
cc34aca2a0 | ||
|
|
fde4e9b38a | ||
|
|
c55143d8c9 | ||
|
|
8973e93cb6 | ||
|
|
8c9cac2655 | ||
|
|
ed8547ccc1 | ||
|
|
e7e53a8b8c | ||
|
|
02249491f8 | ||
|
|
cf0892922b | ||
|
|
99f31a7c26 | ||
|
|
68373604dd | ||
|
|
2d6d5df0e7 | ||
|
|
a897b31166 | ||
|
|
fb92906c3a | ||
|
|
c018f29ad7 | ||
|
|
5367463239 | ||
|
|
6c9147483c | ||
|
|
d123d7f335 | ||
|
|
da8ca08c36 | ||
|
|
307caaa3ef | ||
|
|
6c696b46c8 | ||
|
|
42155238b7 | ||
|
|
92edc26a30 | ||
|
|
e36499c483 | ||
|
|
6215e1ac01 | ||
|
|
74b39e16f9 | ||
|
|
a1d8538c64 | ||
|
|
1d7cbc2a4e | ||
|
|
954fb4f0c8 | ||
|
|
901333f7e4 | ||
|
|
0b381467ca | ||
|
|
6188dc6fb7 | ||
|
|
802754c24c | ||
|
|
6c843228eb | ||
|
|
a3979f63e0 | ||
|
|
52c560c30d | ||
|
|
e88be7e61a | ||
|
|
a4e965434f | ||
|
|
096d214a88 | ||
|
|
afb7fc32e7 | ||
|
|
641bbc9351 | ||
|
|
136c6082f6 | ||
|
|
b9a20d2923 | ||
|
|
74eb2ac0b9 | ||
|
|
51222f5607 | ||
|
|
d6d1a4ced2 | ||
|
|
4b086cebcd | ||
|
|
1f3550c9bd | ||
|
|
912008b048 | ||
|
|
5ad8b03831 | ||
|
|
c1e515a05f | ||
|
|
654593b4b6 | ||
|
|
8999173aa0 | ||
|
|
10b087640f | ||
|
|
d0392d25ed | ||
|
|
2ffc6ba42a | ||
|
|
c114a2edaa | ||
|
|
63db4d5120 | ||
|
|
d8c73ed472 | ||
|
|
5971bfbfa6 | ||
|
|
29eacd6424 | ||
|
|
21ca87be38 | ||
|
|
1283314f77 | ||
|
|
9c54e2e6b0 | ||
|
|
a5efb95065 | ||
|
|
625f235740 | ||
|
|
2c122d413d | ||
|
|
fc0c99a232 | ||
|
|
24e274200f | ||
|
|
0aab3f3c7a | ||
|
|
182d809028 | ||
|
|
c51265dafb | ||
|
|
0cb039d35d | ||
|
|
7ab0fd3028 | ||
|
|
49f0fa423c | ||
|
|
61e63e411d | ||
|
|
9339e88a5a | ||
|
|
fe003b927c | ||
|
|
f5b5b1bd85 | ||
|
|
d28bfac81f | ||
|
|
b04e3e8ecf | ||
|
|
d77d8eb068 | ||
|
|
7cd88aca25 | ||
|
|
b5e6371eaa | ||
|
|
544b98c1d0 | ||
|
|
3188e92257 | ||
|
|
3fa2f9a162 | ||
|
|
7b1f6b8857 | ||
|
|
17d8893bdb | ||
|
|
0e44f245af | ||
|
|
824e8f1a0f | ||
|
|
6e4d2a4a33 | ||
|
|
6c65bd34cd | ||
|
|
7bfe4834d0 | ||
|
|
484c2f6ef2 | ||
|
|
87956ea725 | ||
|
|
32dd403038 | ||
|
|
4d59e72866 | ||
|
|
9ac5d51187 | ||
|
|
5a031f5d1b | ||
|
|
535bc9f46b | ||
|
|
f0c144c51c | ||
|
|
61e4ea45fb | ||
|
|
06e1656923 | ||
|
|
0a3b1c6530 | ||
|
|
d479817b6a | ||
|
|
01bf31d23d | ||
|
|
42a861d206 | ||
|
|
78266e3e4c | ||
|
|
c8478d75be | ||
|
|
28d93b00a3 | ||
|
|
12a7a6a5c5 | ||
|
|
a6d5071724 | ||
|
|
cebe2242b9 | ||
|
|
56ee7d946f | ||
|
|
f3c6521f2b | ||
|
|
ffed465f09 | ||
|
|
c359b5be06 | ||
|
|
e9a023bb71 | ||
|
|
60f0b28076 | ||
|
|
d541c9ab4a | ||
|
|
024ed53022 | ||
|
|
2c78bd1b46 | ||
|
|
5602d79611 | ||
|
|
51b73c9c31 | ||
|
|
10f0580a43 | ||
|
|
a1488565ea | ||
|
|
35d5f887ce | ||
|
|
4c76de45ed | ||
|
|
68fc9c0659 | ||
|
|
2952b15755 | ||
|
|
ef1d599662 | ||
|
|
4e49d3932a | ||
|
|
86d3c08494 | ||
|
|
7b4ccd1f30 | ||
|
|
f145903eb0 | ||
|
|
d3bc1797b6 | ||
|
|
db94f81937 | ||
|
|
b03e91b653 | ||
|
|
505bdcb8ba | ||
|
|
f103a54790 | ||
|
|
e1de593dcd | ||
|
|
45f42772b1 | ||
|
|
98152640b1 | ||
|
|
04e235e805 | ||
|
|
ae737dddaa | ||
|
|
f565c702e5 | ||
|
|
f945b44bc9 | ||
|
|
857b9cc864 | ||
|
|
bf042563e9 | ||
|
|
49f1ab2f75 | ||
|
|
e46f60ac8d | ||
|
|
5c9e504291 | ||
|
|
7fe83f8087 | ||
|
|
43f0114c57 | ||
|
|
1a41b05f60 | ||
|
|
81315790a8 | ||
|
|
8c8fc2304d | ||
|
|
15ece0ab30 | ||
|
|
5550729120 | ||
|
|
9872608d61 | ||
|
|
be52660227 | ||
|
|
237342e876 | ||
|
|
cfbfbc9753 | ||
|
|
aefb308536 | ||
|
|
031181ad2a | ||
|
|
dbf3da41f3 | ||
|
|
3a2902789e | ||
|
|
459a4fd727 | ||
|
|
2ecc1abbad | ||
|
|
92c57ada1a | ||
|
|
fceb6fa7b4 | ||
|
|
c290c027fb | ||
|
|
ca205a8c73 | ||
|
|
968cf0b307 | ||
|
|
fd8bee94a4 | ||
|
|
41ac1be082 | ||
|
|
dd9b1d26ea | ||
|
|
4b829757b2 | ||
|
|
b5b01cb6dd | ||
|
|
287314f016 | ||
|
|
73e7e0b1c5 | ||
|
|
d070b9a778 | ||
|
|
d976bf5965 | ||
|
|
052ac008c3 | ||
|
|
57a2b2bc83 | ||
|
|
043f82ad79 | ||
|
|
ba61cdba4e | ||
|
|
dcd1ae96e0 | ||
|
|
1fdb058386 | ||
|
|
29cb5513a0 | ||
|
|
6db57d9f27 | ||
|
|
1a77bd9914 | ||
|
|
350335711b | ||
|
|
988c425150 | ||
|
|
23827ba1d1 | ||
|
|
7d36bda769 | ||
|
|
8c559ea067 | ||
|
|
88832d4bc9 | ||
|
|
f5cece3b0e | ||
|
|
d5485238b8 | ||
|
|
ac5a121f66 | ||
|
|
481df3bcb9 | ||
|
|
7677a3de2c | ||
|
|
1f65c01b04 | ||
|
|
d5928f6fea | ||
|
|
bef77ac8dc | ||
|
|
c8eb034c49 | ||
|
|
c77167df46 | ||
|
|
3717a663d9 | ||
|
|
5814549cbe | ||
|
|
2e5d268798 | ||
|
|
4ed312251e | ||
|
|
946c534b08 | ||
|
|
883877adec | ||
|
|
215531d65c | ||
|
|
c0f055c3c0 | ||
|
|
d77044882d | ||
|
|
d6795300b1 | ||
|
|
fd3c76ffa3 | ||
|
|
698bc3a35a | ||
|
|
1bcb50edc3 | ||
|
|
9700afb9cb | ||
|
|
9ce82fb205 | ||
|
|
2935236ace | ||
|
|
c821b675b8 | ||
|
|
a09d529027 | ||
|
|
b62b61fb01 | ||
|
|
df5c1ed1f8 | ||
|
|
f4af35f86b | ||
|
|
657a51f7ed | ||
|
|
575b2f71e9 | ||
|
|
97f7326da4 | ||
|
|
242d87a54b | ||
|
|
c111b79147 | ||
|
|
61bf14225b | ||
|
|
c1e98411b6 | ||
|
|
b25e95fc4a | ||
|
|
3cc82d8522 | ||
|
|
ea4e48680c | ||
|
|
f403eed12c | ||
|
|
388a874922 | ||
|
|
9a4aab465a | ||
|
|
a052cd6619 | ||
|
|
31a803b243 | ||
|
|
1d2e41c04e | ||
|
|
b650d6d423 | ||
|
|
156aad3057 | ||
|
|
05bfe00924 | ||
|
|
035b2c022b | ||
|
|
61b62d4612 | ||
|
|
dc5d7bb2f3 | ||
|
|
5e9096e328 | ||
|
|
34b4ba514f | ||
|
|
d217083059 | ||
|
|
bdcef60cab | ||
|
|
14f59ce3f3 | ||
|
|
31ad904367 | ||
|
|
04fcf1110e | ||
|
|
eb9b6433ae | ||
|
|
b9489b5e9a | ||
|
|
bd1c69b7b7 | ||
|
|
23dc235bac | ||
|
|
2440379cd1 | ||
|
|
6c00aaa3ef | ||
|
|
00259f8819 | ||
|
|
decf8ec70b | ||
|
|
c24a5546a5 | ||
|
|
312421d777 | ||
|
|
c42a29a66c | ||
|
|
afc317adf7 | ||
|
|
256f74d0a3 | ||
|
|
20d3f780a2 | ||
|
|
6d6dc6646a | ||
|
|
3d402fc0ca | ||
|
|
b874681824 | ||
|
|
97cbdfb1ef | ||
|
|
24889f9ebc | ||
|
|
e0ec607198 | ||
|
|
d29fca155e | ||
|
|
e2e26b53b3 | ||
|
|
948efbd9c1 | ||
|
|
f03b80f9d7 | ||
|
|
38d7ee4432 | ||
|
|
f66e8e8b44 | ||
|
|
ee133dbceb | ||
|
|
68e4b67bd2 | ||
|
|
553147a1c0 | ||
|
|
31ae8cac96 | ||
|
|
7691622274 | ||
|
|
5d1be367c3 | ||
|
|
ed0e566e99 | ||
|
|
2793eb4ebd | ||
|
|
059073d4c2 | ||
|
|
e19b33fc2e | ||
|
|
cbe7aa6eec | ||
|
|
2a457ac8e9 | ||
|
|
0d4d5386c7 | ||
|
|
f820fc8301 | ||
|
|
131f470757 | ||
|
|
6c35570e78 | ||
|
|
869c4c5871 | ||
|
|
a65c0b3da3 | ||
|
|
3042de2ce1 | ||
|
|
f57c8d347e | ||
|
|
5b3ff7b879 | ||
|
|
9fff6ec3b6 | ||
|
|
ca5e754aea | ||
|
|
ebcf861aa6 | ||
|
|
966a566ade | ||
|
|
5fa15f6098 | ||
|
|
53f212fd3a | ||
|
|
21cb3310d6 | ||
|
|
4dc0b2f37f | ||
|
|
ac6df536ef | ||
|
|
c37386f8b2 | ||
|
|
c3a03db8b0 | ||
|
|
28c85990ba | ||
|
|
05b443d984 | ||
|
|
8326bfd136 | ||
|
|
b2e89934de | ||
|
|
c726c1621b | ||
|
|
b71c84c355 | ||
|
|
3896b7bb3b | ||
|
|
cb2a9f9f7d | ||
|
|
35b227cd17 | ||
|
|
f8a7467ec0 | ||
|
|
bf710aec56 | ||
|
|
005702e5b6 | ||
|
|
66d47bf933 | ||
|
|
d6104bbb35 | ||
|
|
a0e22036c8 | ||
|
|
21bf49c061 | ||
|
|
a408ef797b | ||
|
|
f1154257c5 | ||
|
|
0ca78bef8d | ||
|
|
dc5968cd30 | ||
|
|
63a0c08696 | ||
|
|
6c415e7769 | ||
|
|
90bdd29fb6 | ||
|
|
e0db4695ac | ||
|
|
de648dd6da | ||
|
|
73c82ae43a | ||
|
|
ba256c76bc | ||
|
|
5e2e947fe0 | ||
|
|
f4281e4f69 | ||
|
|
3c87e4ec14 | ||
|
|
c55fef057c | ||
|
|
6f54ee5d66 | ||
|
|
9efab5f3e8 | ||
|
|
364f5b38b9 | ||
|
|
5d78445501 | ||
|
|
8ec2388269 | ||
|
|
dbacdb5bf0 | ||
|
|
f4c6cff461 | ||
|
|
0b9cbf47e3 | ||
|
|
bda178c2bb | ||
|
|
6bd6cefaa6 | ||
|
|
83be1e0b49 | ||
|
|
cf3fe0be84 | ||
|
|
ec76e1c111 | ||
|
|
6004f84845 | ||
|
|
3ec98736cf | ||
|
|
ce24372c57 | ||
|
|
4614769b84 | ||
|
|
86d2b5f59f | ||
|
|
1efd1d182d | ||
|
|
0a24ab8001 | ||
|
|
02cacba5c5 | ||
|
|
38653e2aa4 | ||
|
|
8cc9b159a5 | ||
|
|
990c8af3d1 | ||
|
|
4c33793678 | ||
|
|
9e06f70380 | ||
|
|
22f7d64bf0 | ||
|
|
630327c979 | ||
|
|
662506260e | ||
|
|
8e66af627a | ||
|
|
270c30334d | ||
|
|
c73c3ceb5e | ||
|
|
22725d30f4 | ||
|
|
76b753f9f2 | ||
|
|
453a765107 | ||
|
|
f03645d545 | ||
|
|
55273d68c9 | ||
|
|
4e05b82f02 | ||
|
|
2597907578 | ||
|
|
debef9a66b | ||
|
|
9122e75101 | ||
|
|
fe1c4b18cd | ||
|
|
e571996cb5 | ||
|
|
fb862d3ec3 | ||
|
|
26f01f205b | ||
|
|
c37a3e0ed1 | ||
|
|
eb689eb56e | ||
|
|
60bad9e985 | ||
|
|
e21ee8a871 | ||
|
|
04006eb5cc | ||
|
|
84f1d5c906 | ||
|
|
983e989be1 | ||
|
|
c843a60131 | ||
|
|
56a8b5d0c0 | ||
|
|
f0dce41fbc | ||
|
|
0111a58dac | ||
|
|
50e4c5c314 | ||
|
|
5a6dfd9e50 | ||
|
|
75fbfee4d8 | ||
|
|
65ee500ef3 | ||
|
|
80f108e5d6 | ||
|
|
9b2d622990 | ||
|
|
adf74586af | ||
|
|
b45cf68295 | ||
|
|
d9dd67c51f | ||
|
|
abf17f6211 | ||
|
|
57cb8f8795 | ||
|
|
fcb18b8c3c | ||
|
|
796bc7ed34 | ||
|
|
72061ba427 | ||
|
|
d04167cada | ||
|
|
f83bab9e17 | ||
|
|
4ba68938dd | ||
|
|
658a9ca6dd | ||
|
|
7e5d16be9b | ||
|
|
8d6c1e5c08 | ||
|
|
ce6e27d0ff | ||
|
|
3ebff09d63 | ||
|
|
ccc18d716f | ||
|
|
ec626ee797 | ||
|
|
c810fec8c4 | ||
|
|
9e88926283 | ||
|
|
731113183e | ||
|
|
4627f365a2 | ||
|
|
1762629596 | ||
|
|
2f7646105e | ||
|
|
980780e48b | ||
|
|
b65e693e12 | ||
|
|
734c6813ea | ||
|
|
0d31c0ec6c | ||
|
|
4806c1e09b | ||
|
|
cf3084cfa8 | ||
|
|
9881a1df9e |
@@ -1,32 +1,23 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||
{
|
||||
"name": "pocket-id",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/caddy:1": {}
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.26"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"svelte.svelte-vscode"
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// Install npm dependencies for the frontend.
|
||||
"postCreateCommand": "npm install --prefix frontend"
|
||||
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
"containerEnv": {
|
||||
"HOST": "0.0.0.0"
|
||||
},
|
||||
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
@@ -6,6 +6,9 @@ node_modules
|
||||
/frontend/.svelte-kit
|
||||
/frontend/build
|
||||
/backend/bin
|
||||
/backend/frontend/dist
|
||||
/tests/.auth
|
||||
/tests/.report
|
||||
|
||||
|
||||
# Env
|
||||
@@ -15,4 +18,5 @@ node_modules
|
||||
|
||||
# Application specific
|
||||
data
|
||||
/scripts/development
|
||||
/scripts/development
|
||||
/backend/GeoLite2-City.mmdb
|
||||
14
.env.example
14
.env.example
@@ -1,5 +1,17 @@
|
||||
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
|
||||
# These variables must be configured for your deployment:
|
||||
APP_URL=https://your-pocket-id-domain.com
|
||||
|
||||
# Encryption key (choose one method):
|
||||
# Method 1: Direct key (simple but less secure)
|
||||
# Generate with: openssl rand -base64 32
|
||||
ENCRYPTION_KEY=
|
||||
# Method 2: File-based key (recommended)
|
||||
# Put the base64 key in a file and point to it here.
|
||||
# ENCRYPTION_KEY_FILE=/path/to/encryption_key
|
||||
|
||||
# These variables are optional but recommended to review:
|
||||
TRUST_PROXY=false
|
||||
MAXMIND_LICENSE_KEY=
|
||||
PUID=1000
|
||||
|
||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
||||
* @pocket-id/maintainers
|
||||
2
.github/FUNDING.yml
vendored
2
.github/FUNDING.yml
vendored
@@ -1,2 +1,2 @@
|
||||
# These are supported funding model platforms
|
||||
github: stonith404
|
||||
github: [stonith404, kmendell]
|
||||
|
||||
24
.github/ISSUE_TEMPLATE/bug.yml
vendored
24
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: "Report something that is not working as expected"
|
||||
title: "🐛 Bug Report: "
|
||||
labels: [bug]
|
||||
type: 'Bug'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -36,13 +36,29 @@ body:
|
||||
value: |
|
||||
### Additional Information
|
||||
- type: textarea
|
||||
id: extra-information
|
||||
id: version
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Version and Environment"
|
||||
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||
label: "Pocket ID Version"
|
||||
description: "Please specify the version of Pocket ID."
|
||||
placeholder: "e.g., v0.24.1"
|
||||
- type: textarea
|
||||
id: database
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Database"
|
||||
description: "Please specify the database in use: SQLite or Postgres (including version)."
|
||||
placeholder: "e.g., SQLite or Postgres 17"
|
||||
- type: textarea
|
||||
id: environment
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "OS and Environment"
|
||||
description: "Please include the OS, whether you're using containers (Docker, Podman, etc) along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||
placeholder: "e.g., Docker on Ubuntu 24.04, served using Traefik"
|
||||
- type: textarea
|
||||
id: log-files
|
||||
validations:
|
||||
|
||||
3
.github/ISSUE_TEMPLATE/feature.yml
vendored
3
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,7 +1,8 @@
|
||||
name: 🚀 Feature
|
||||
description: "Submit a proposal for a new feature"
|
||||
title: "🚀 Feature: "
|
||||
labels: [feature]
|
||||
type: "Feature"
|
||||
labels: ["needs more upvotes"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: "🌐 Language request"
|
||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||
title: "🌐 Language Request: <language name in english>"
|
||||
labels: [language-request]
|
||||
type: 'Language Request'
|
||||
body:
|
||||
- type: input
|
||||
id: language-name-native
|
||||
|
||||
91
.github/dependabot.yml
vendored
91
.github/dependabot.yml
vendored
@@ -1,12 +1,83 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for more information:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
# https://containers.dev/guide/dependabot
|
||||
|
||||
version: 2
|
||||
|
||||
multi-ecosystem-groups:
|
||||
all-dependencies:
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "friday"
|
||||
time: "17:00"
|
||||
timezone: "Europe/Zurich"
|
||||
labels:
|
||||
- "dependencies"
|
||||
open-pull-requests-limit: 2
|
||||
pull-request-branch-name:
|
||||
separator: "-"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
include: "scope"
|
||||
|
||||
updates:
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: weekly
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "all-dependencies"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directory: "/backend"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "all-dependencies"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "all-dependencies"
|
||||
versioning-strategy: "increase-if-necessary"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories:
|
||||
- "/docker"
|
||||
- "/tests/setup"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "all-dependencies"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "docker-compose"
|
||||
directories:
|
||||
- "/"
|
||||
- "/tests/setup"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "all-dependencies"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "devcontainers"
|
||||
directory: "/"
|
||||
patterns: ["*"]
|
||||
multi-ecosystem-group: "all-dependencies"
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types: ["version-update:semver-major"]
|
||||
cooldown:
|
||||
default-days: 7
|
||||
|
||||
12
.github/pull_request_template.md
vendored
Normal file
12
.github/pull_request_template.md
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
## What does this PR do?
|
||||
|
||||
## Screenshots
|
||||
|
||||
## AI Usage
|
||||
|
||||
## Contributing Guidelines
|
||||
|
||||
Have you read the [Contributing Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md)?
|
||||
|
||||
- [ ] If I'm implementing a new feature, I have opened an issue first, or commented on an existing one, to discuss the implementation details.
|
||||
- [ ] I have read the [AI Usage Guidelines](https://github.com/pocket-id/pocket-id/blob/main/CONTRIBUTING.md#ai-usage-policy).
|
||||
21
.github/svelte-check-matcher.json
vendored
Normal file
21
.github/svelte-check-matcher.json
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"problemMatcher": [
|
||||
{
|
||||
"owner": "svelte-check",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^([^\\s].*):(\\d+):(\\d+)$",
|
||||
"file": 1,
|
||||
"line": 2,
|
||||
"column": 3
|
||||
},
|
||||
{
|
||||
"regexp": "^\\s*(Error|Warning):\\s*(.*)\\s+\\((?:ts|js|svelte)\\)$",
|
||||
"severity": 1,
|
||||
"message": 2,
|
||||
"loop": false
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
14
.github/workflows/backend-linter.yml
vendored
14
.github/workflows/backend-linter.yml
vendored
@@ -6,7 +6,7 @@ on:
|
||||
paths:
|
||||
- "backend/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, breaking/**]
|
||||
paths:
|
||||
- "backend/**"
|
||||
|
||||
@@ -17,23 +17,25 @@ permissions:
|
||||
pull-requests: read
|
||||
# Optional: allow write access to checks to allow the action to annotate code in the PR.
|
||||
checks: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
golangci-lint:
|
||||
name: Run Golangci-lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
|
||||
- name: Run Golangci-lint
|
||||
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
|
||||
uses: golangci/golangci-lint-action@v9.3.0
|
||||
with:
|
||||
version: v2.0.2
|
||||
version: v2.12.2
|
||||
args: --config=.golangci.yml
|
||||
working-directory: backend
|
||||
only-new-issues: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: 'Login to GitHub Container Registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.repository_owner}}
|
||||
password: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
92
.github/workflows/build-next.yml
vendored
Normal file
92
.github/workflows/build-next.yml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
name: Build Next Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: build-next-image
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
jobs:
|
||||
build-next:
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Setup depot buildx driver
|
||||
run: depot configure-docker
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7.2.3
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --clean --skip=announce,validate --parallelism=4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_NEXT: "true"
|
||||
SKIP_RELEASE: "true"
|
||||
SKIP_CHANGELOG: "true"
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
265
.github/workflows/e2e-tests.yml
vendored
265
.github/workflows/e2e-tests.yml
vendored
@@ -1,178 +1,173 @@
|
||||
name: E2E Tests
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [ main, breaking/** ]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
test:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
timeout-minutes: 20
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-24.04-32
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- db: sqlite
|
||||
storage: filesystem
|
||||
- db: postgres
|
||||
storage: filesystem
|
||||
- db: sqlite
|
||||
storage: s3
|
||||
- db: sqlite
|
||||
storage: database
|
||||
- db: postgres
|
||||
storage: database
|
||||
|
||||
steps:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build and export
|
||||
uses: docker/build-push-action@v6
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
tags: pocket-id/pocket-id:test
|
||||
outputs: type=docker,dest=/tmp/docker-image.tar
|
||||
build-args: BUILD_TAGS=e2etest
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Upload Docker image artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Set up Depot Docker builder
|
||||
run: depot configure-docker
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v5
|
||||
id: playwright-cache
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp/docker-image.tar
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
|
||||
test-sqlite:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- name: Cache PostgreSQL Docker image
|
||||
uses: actions/cache@v5
|
||||
id: postgres-cache
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
path: /tmp/postgres-image.tar
|
||||
key: postgres-17-${{ runner.os }}
|
||||
- name: Pull and save PostgreSQL image
|
||||
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker pull postgres:17
|
||||
docker save postgres:17 > /tmp/postgres-image.tar
|
||||
- name: Load PostgreSQL image
|
||||
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/postgres-image.tar
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
- name: Cache SCIM Test Server Docker image
|
||||
uses: actions/cache@v5
|
||||
id: scim-cache
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
path: /tmp/scim-test-server-image.tar
|
||||
key: scim-test-server-${{ runner.os }}
|
||||
- name: Pull and save SCIM Test Server image
|
||||
if: steps.scim-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker pull ghcr.io/pocket-id/scim-test-server
|
||||
docker save ghcr.io/pocket-id/scim-test-server > /tmp/scim-test-server-image.tar
|
||||
- name: Load SCIM Test Server image
|
||||
if: steps.scim-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/scim-test-server-image.tar
|
||||
|
||||
- name: Load Docker Image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
- name: Cache Localstack S3 Docker image
|
||||
if: matrix.storage == 's3'
|
||||
uses: actions/cache@v5
|
||||
id: s3-cache
|
||||
with:
|
||||
path: /tmp/localstack-s3-image.tar
|
||||
key: localstack-4.14.0-${{ runner.os }}
|
||||
- name: Pull and save Localstack S3 image
|
||||
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker pull localstack/localstack:4.14.0
|
||||
docker save localstack/localstack:4.14.0 > /tmp/localstack-s3-image.tar
|
||||
- name: Load Localstack S3 image
|
||||
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/localstack-s3-image.tar
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
- name: Install test dependencies
|
||||
run: pnpm --filter pocket-id-tests install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: ./frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
working-directory: ./tests
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Run Docker Container with Sqlite DB
|
||||
- name: Run Docker containers
|
||||
working-directory: ./tests/setup
|
||||
run: |
|
||||
docker run -d --name pocket-id-sqlite \
|
||||
-p 80:80 \
|
||||
-e APP_ENV=test \
|
||||
pocket-id/pocket-id:test
|
||||
DOCKER_COMPOSE_FILE=docker-compose.yml
|
||||
|
||||
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
|
||||
cat > .env <<EOF
|
||||
FILE_BACKEND=${{ matrix.storage }}
|
||||
SCIM_SERVICE_PROVIDER_URL=http://localhost:18123/v2
|
||||
SCIM_SERVICE_PROVIDER_URL_INTERNAL=http://scim-test-server:8080/v2
|
||||
EOF
|
||||
|
||||
if [ "${{ matrix.db }}" = "postgres" ]; then
|
||||
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
|
||||
elif [ "${{ matrix.storage }}" = "s3" ]; then
|
||||
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
|
||||
fi
|
||||
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" up -d --build
|
||||
|
||||
{
|
||||
LOG_FILE="/tmp/backend.log"
|
||||
while true; do
|
||||
CID=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q pocket-id)
|
||||
if [ -n "$CID" ]; then
|
||||
echo "[$(date)] Attaching logs for $CID" >> "$LOG_FILE"
|
||||
docker logs -f --since=0 "$CID" >> "$LOG_FILE" 2>&1
|
||||
else
|
||||
echo "[$(date)] Container not yet running…" >> "$LOG_FILE"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
} &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
working-directory: ./tests
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: playwright-report-sqlite
|
||||
path: frontend/tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
name: backend-sqlite
|
||||
path: /tmp/backend.log
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
test-postgres:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
- name: Load Docker Image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: ./frontend
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create pocket-id-network
|
||||
|
||||
- name: Start Postgres DB
|
||||
run: |
|
||||
docker run -d --name pocket-id-db \
|
||||
--network pocket-id-network \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=pocket-id \
|
||||
-p 5432:5432 \
|
||||
postgres:17
|
||||
|
||||
- name: Wait for Postgres to start
|
||||
run: |
|
||||
for i in {1..10}; do
|
||||
if docker exec pocket-id-db pg_isready -U postgres; then
|
||||
echo "Postgres is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run Docker Container with Postgres DB
|
||||
run: |
|
||||
docker run -d --name pocket-id-postgres \
|
||||
--network pocket-id-network \
|
||||
-p 80:80 \
|
||||
-e APP_ENV=test \
|
||||
-e DB_PROVIDER=postgres \
|
||||
-e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
|
||||
pocket-id/pocket-id:test
|
||||
|
||||
docker logs -f pocket-id-postgres &> /tmp/backend.log &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- name: Upload Test Report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: playwright-report-postgres
|
||||
path: frontend/tests/.report
|
||||
name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}
|
||||
path: tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
- name: Upload Backend Test Report
|
||||
uses: actions/upload-artifact@v7
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: backend-postgres
|
||||
name: backend-${{ matrix.db }}-${{ matrix.storage }}
|
||||
path: /tmp/backend.log
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
106
.github/workflows/pr-quality.yml
vendored
Normal file
106
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
# General Settings
|
||||
max-failures: 4
|
||||
|
||||
# PR Branch Checks
|
||||
allowed-target-branches: "main"
|
||||
blocked-target-branches: ""
|
||||
allowed-source-branches: ""
|
||||
blocked-source-branches: ""
|
||||
|
||||
# PR Quality Checks
|
||||
max-negative-reactions: 0
|
||||
require-maintainer-can-modify: true
|
||||
|
||||
# PR Title Checks
|
||||
require-conventional-title: true
|
||||
|
||||
# PR Description Checks
|
||||
require-description: true
|
||||
max-description-length: 2500
|
||||
max-emoji-count: 0
|
||||
max-code-references: 0
|
||||
require-linked-issue: false
|
||||
blocked-terms: ""
|
||||
blocked-issue-numbers: ""
|
||||
|
||||
# PR Template Checks
|
||||
require-pr-template: true
|
||||
strict-pr-template-sections: "Contributing Guidelines"
|
||||
optional-pr-template-sections: "Issues"
|
||||
max-additional-pr-template-sections: 3
|
||||
|
||||
# Commit Message Checks
|
||||
max-commit-message-length: 500
|
||||
require-conventional-commits: false
|
||||
require-commit-author-match: true
|
||||
blocked-commit-authors: ""
|
||||
|
||||
# File Checks
|
||||
allowed-file-extensions: ""
|
||||
allowed-paths: ""
|
||||
blocked-paths: |
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
require-final-newline: false
|
||||
max-added-comments: 0
|
||||
|
||||
# User Checks
|
||||
detect-spam-usernames: true
|
||||
min-account-age: 30
|
||||
max-daily-forks: 7
|
||||
min-profile-completeness: 4
|
||||
|
||||
# Merge Checks
|
||||
min-repo-merged-prs: 0
|
||||
min-repo-merge-ratio: 0
|
||||
min-global-merge-ratio: 30
|
||||
global-merge-ratio-exclude-own: false
|
||||
|
||||
# Exemptions
|
||||
exempt-draft-prs: false
|
||||
exempt-bots: |
|
||||
actions-user
|
||||
dependabot[bot]
|
||||
renovate[bot]
|
||||
github-actions[bot]
|
||||
exempt-users: "James18232"
|
||||
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
|
||||
exempt-label: "quality/exempt"
|
||||
exempt-pr-label: ""
|
||||
exempt-all-milestones: false
|
||||
exempt-all-pr-milestones: false
|
||||
exempt-milestones: ""
|
||||
exempt-pr-milestones: ""
|
||||
|
||||
# PR Success Actions
|
||||
success-add-pr-labels: "quality/verified"
|
||||
|
||||
# PR Failure Actions
|
||||
failure-remove-pr-labels: ""
|
||||
failure-remove-all-pr-labels: true
|
||||
failure-add-pr-labels: "quality/rejected"
|
||||
failure-pr-message: |
|
||||
This PR did not pass quality checks so it will be closed.
|
||||
See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}) for details on which checks failed.
|
||||
|
||||
If you believe this is a mistake please let us know.
|
||||
|
||||
close-pr: true
|
||||
lock-pr: false
|
||||
91
.github/workflows/release.yml
vendored
Normal file
91
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,91 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
id-token: write
|
||||
attestations: write
|
||||
artifact-metadata: write
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: depot-ubuntu-24.04-16
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
cache-dependency-path: backend/go.sum
|
||||
|
||||
- name: Set up Depot CLI
|
||||
uses: depot/setup-action@v1
|
||||
|
||||
- name: Setup depot buildx driver
|
||||
run: depot configure-docker
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v4.4.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v7.2.3
|
||||
with:
|
||||
distribution: goreleaser-pro
|
||||
version: "~> v2"
|
||||
args: release --clean --skip=validate --parallelism=4
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GORELEASER_KEY: ${{ secrets.GORELEASER_KEY }}
|
||||
MACOS_SIGN_P12: ${{ secrets.MACOS_SIGN_P12 }}
|
||||
MACOS_SIGN_PASSWORD: ${{ secrets.MACOS_SIGN_PASSWORD }}
|
||||
MACOS_NOTARY_KEY: ${{ secrets.MACOS_NOTARY_KEY }}
|
||||
MACOS_NOTARY_KEY_ID: ${{ secrets.MACOS_NOTARY_KEY_ID }}
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
DISCORD_WEBHOOK_ID: ${{ secrets.DISCORD_WEBHOOK_ID }}
|
||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
- name: Publish release
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||
62
.github/workflows/svelte-check.yml
vendored
Normal file
62
.github/workflows/svelte-check.yml
vendored
Normal file
@@ -0,0 +1,62 @@
|
||||
name: Svelte Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
pull_request:
|
||||
branches: [main, breaking/**]
|
||||
paths:
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
type-check:
|
||||
name: Run Svelte Check
|
||||
# Don't run on dependabot branches
|
||||
if: github.actor != 'dependabot[bot]'
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
- name: Build Pocket ID Frontend
|
||||
working-directory: frontend
|
||||
run: pnpm --filter pocket-id-frontend build
|
||||
|
||||
- name: Add svelte-check problem matcher
|
||||
run: echo "::add-matcher::.github/svelte-check-matcher.json"
|
||||
|
||||
- name: Run svelte-check
|
||||
working-directory: frontend
|
||||
run: pnpm --filter pocket-id-frontend check
|
||||
21
.github/workflows/unit-tests.yml
vendored
21
.github/workflows/unit-tests.yml
vendored
@@ -5,19 +5,24 @@ on:
|
||||
paths:
|
||||
- "backend/**"
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, breaking/**]
|
||||
paths:
|
||||
- "backend/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
actions: write
|
||||
|
||||
jobs:
|
||||
test-backend:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: 'backend/go.mod'
|
||||
cache-dependency-path: 'backend/go.sum'
|
||||
go-version-file: "backend/go.mod"
|
||||
cache-dependency-path: "backend/go.sum"
|
||||
- name: Install dependencies
|
||||
working-directory: backend
|
||||
run: |
|
||||
@@ -26,8 +31,8 @@ jobs:
|
||||
working-directory: backend
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
go test -v ./... | tee /tmp/TestResults.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
go test -tags=exclude_frontend,unit -v ./... | tee /tmp/TestResults.log
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: backend-unit-tests
|
||||
|
||||
30
.github/workflows/update-aaguids.yml
vendored
30
.github/workflows/update-aaguids.yml
vendored
@@ -5,16 +5,18 @@ on:
|
||||
- cron: "0 0 * * 1" # Runs every Monday at midnight
|
||||
workflow_dispatch: # Allows manual triggering of the workflow
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
update-aaguids:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: depot-ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Fetch JSON data
|
||||
run: |
|
||||
@@ -24,11 +26,15 @@ jobs:
|
||||
run: |
|
||||
mkdir -p backend/resources
|
||||
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
|
||||
rm data.json
|
||||
|
||||
- name: Commit changes
|
||||
run: |
|
||||
git config --local user.email "action@github.com"
|
||||
git config --local user.name "GitHub Action"
|
||||
git add backend/resources/aaguids.json
|
||||
git diff --staged --quiet || git commit -m "chore: update AAGUIDs"
|
||||
git push
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
commit-message: "chore: update AAGUIDs"
|
||||
title: "chore: update AAGUIDs"
|
||||
body: |
|
||||
This PR updates the AAGUIDs file with the latest data from the [passkey-aaguids](https://github.com/pocket-id/passkey-aaguids) repository.
|
||||
branch: update-aaguids
|
||||
base: main
|
||||
delete-branch: true
|
||||
|
||||
19
.gitignore
vendored
19
.gitignore
vendored
@@ -1,14 +1,21 @@
|
||||
# JetBrains
|
||||
**/.idea
|
||||
|
||||
# Node
|
||||
node_modules
|
||||
|
||||
# PNPM
|
||||
.pnpm-store/
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
/frontend/.svelte-kit
|
||||
/frontend/build
|
||||
/backend/bin
|
||||
pocket-id
|
||||
/tests/test-results/*.json
|
||||
.tmp/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -17,7 +24,7 @@ Thumbs.db
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.development-example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
@@ -30,13 +37,15 @@ vite.config.ts.timestamp-*
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/backend/.bin
|
||||
/pocket-id
|
||||
|
||||
# Application specific
|
||||
data
|
||||
/frontend/tests/.auth
|
||||
/frontend/tests/.report
|
||||
pocket-id-backend
|
||||
/tests/.auth
|
||||
/tests/.report
|
||||
/backend/GeoLite2-City.mmdb
|
||||
/backend/frontend/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
@@ -51,3 +60,5 @@ yarn-error.log*
|
||||
|
||||
#Debug
|
||||
backend/cmd/__debug_*
|
||||
# Added by goreleaser init:
|
||||
dist/
|
||||
|
||||
247
.goreleaser.yaml
Normal file
247
.goreleaser.yaml
Normal file
@@ -0,0 +1,247 @@
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema-pro.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=jcroql
|
||||
version: 2
|
||||
pro: true
|
||||
|
||||
project_name: pocket-id
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- pnpm install --frozen-lockfile
|
||||
- pnpm -C frontend build
|
||||
|
||||
builds:
|
||||
- id: pocket-id
|
||||
main: ./cmd
|
||||
dir: backend
|
||||
binary: pocket-id
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- GOMAXPROCS=2
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
- freebsd
|
||||
- openbsd
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
- "386"
|
||||
- arm
|
||||
goarm:
|
||||
- "7"
|
||||
goamd64:
|
||||
- v1
|
||||
go386:
|
||||
- sse2
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: "386"
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: "386"
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: openbsd
|
||||
goarch: "386"
|
||||
- goos: openbsd
|
||||
goarch: arm
|
||||
ldflags:
|
||||
- -w -s
|
||||
- -buildid={{ .Version }}
|
||||
- -X github.com/pocket-id/pocket-id/backend/internal/common.Version={{ .Version }}
|
||||
flags:
|
||||
- -trimpath
|
||||
- -p=2
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
skip: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
|
||||
|
||||
- id: pocket-id-next
|
||||
main: ./cmd
|
||||
dir: backend
|
||||
binary: pocket-id
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
- GOMAXPROCS=2
|
||||
targets:
|
||||
- linux_amd64_v1
|
||||
- linux_arm64_v8.0
|
||||
ldflags:
|
||||
- -w -s
|
||||
- -buildid={{ .Version }}
|
||||
- -X github.com/pocket-id/pocket-id/backend/internal/common.Version={{ .Version }}
|
||||
flags:
|
||||
- -trimpath
|
||||
- -p=2
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
skip: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
|
||||
|
||||
archives:
|
||||
- id: default
|
||||
formats:
|
||||
- binary
|
||||
name_template: "{{ .Binary }}_{{ .Os }}_{{ .Arch }}{{ with .Arm }}v{{ . }}{{ end }}"
|
||||
|
||||
checksum:
|
||||
name_template: checksums.txt
|
||||
|
||||
docker_digest:
|
||||
name_template: digests.txt
|
||||
|
||||
dockers_v2:
|
||||
- id: pocket-id
|
||||
ids:
|
||||
- pocket-id
|
||||
dockerfile: docker/Dockerfile-prebuilt
|
||||
images:
|
||||
- "pocketid/pocket-id"
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
|
||||
tags:
|
||||
- "{{ .Tag }}"
|
||||
- "v{{ .Major }}"
|
||||
- "v{{ .Major }}.{{ .Minor }}"
|
||||
- "latest"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
labels:
|
||||
"org.opencontainers.image.authors": "Pocket ID"
|
||||
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
|
||||
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.version": "{{ .Version }}"
|
||||
"org.opencontainers.image.revision": "{{ .FullCommit }}"
|
||||
"org.opencontainers.image.licenses": "BSD-2-Clause"
|
||||
"org.opencontainers.image.ref.name": "pocket-id"
|
||||
"org.opencontainers.image.title": "Pocket ID"
|
||||
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
|
||||
sbom: true
|
||||
extra_files:
|
||||
- scripts/docker
|
||||
|
||||
- id: pocket-id-distroless
|
||||
ids:
|
||||
- pocket-id
|
||||
dockerfile: docker/Dockerfile-distroless
|
||||
images:
|
||||
- "pocketid/pocket-id"
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
|
||||
tags:
|
||||
- "{{ .Tag }}-distroless"
|
||||
- "v{{ .Major }}-distroless"
|
||||
- "v{{ .Major }}.{{ .Minor }}-distroless"
|
||||
- "latest-distroless"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
labels:
|
||||
"org.opencontainers.image.authors": "Pocket ID"
|
||||
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
|
||||
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.version": "{{ .Version }}"
|
||||
"org.opencontainers.image.revision": "{{ .FullCommit }}"
|
||||
"org.opencontainers.image.licenses": "BSD-2-Clause"
|
||||
"org.opencontainers.image.ref.name": "pocket-id"
|
||||
"org.opencontainers.image.title": "Pocket ID"
|
||||
disable: '{{ if index .Env "BUILD_NEXT" }}true{{ end }}'
|
||||
sbom: true
|
||||
|
||||
- id: pocket-id-next
|
||||
ids:
|
||||
- pocket-id-next
|
||||
dockerfile: docker/Dockerfile-prebuilt
|
||||
images:
|
||||
- "pocketid/pocket-id"
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
|
||||
tags:
|
||||
- "next"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
labels:
|
||||
"org.opencontainers.image.authors": "Pocket ID"
|
||||
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
|
||||
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.version": "{{ .Version }}"
|
||||
"org.opencontainers.image.revision": "{{ .FullCommit }}"
|
||||
"org.opencontainers.image.licenses": "BSD-2-Clause"
|
||||
"org.opencontainers.image.ref.name": "pocket-id"
|
||||
"org.opencontainers.image.title": "Pocket ID"
|
||||
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
|
||||
sbom: true
|
||||
extra_files:
|
||||
- scripts/docker
|
||||
|
||||
- id: pocket-id-next-distroless
|
||||
ids:
|
||||
- pocket-id-next
|
||||
dockerfile: docker/Dockerfile-distroless
|
||||
images:
|
||||
- "pocketid/pocket-id"
|
||||
- "ghcr.io/{{ .Env.GITHUB_REPOSITORY_OWNER }}/pocket-id"
|
||||
tags:
|
||||
- "next-distroless"
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
labels:
|
||||
"org.opencontainers.image.authors": "Pocket ID"
|
||||
"org.opencontainers.image.url": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.documentation": "https://github.com/pocket-id/pocket-id/blob/main/README.md"
|
||||
"org.opencontainers.image.source": "https://github.com/pocket-id/pocket-id"
|
||||
"org.opencontainers.image.version": "{{ .Version }}"
|
||||
"org.opencontainers.image.revision": "{{ .FullCommit }}"
|
||||
"org.opencontainers.image.licenses": "BSD-2-Clause"
|
||||
"org.opencontainers.image.ref.name": "pocket-id"
|
||||
"org.opencontainers.image.title": "Pocket ID"
|
||||
disable: '{{ if not (index .Env "BUILD_NEXT") }}true{{ end }}'
|
||||
sbom: true
|
||||
|
||||
notarize:
|
||||
macos:
|
||||
- enabled: '{{ if and (isEnvSet "MACOS_SIGN_P12") (not (index .Env "BUILD_NEXT")) }}true{{ end }}'
|
||||
ids:
|
||||
- pocket-id
|
||||
sign:
|
||||
certificate: "{{ .Env.MACOS_SIGN_P12 }}"
|
||||
password: "{{ .Env.MACOS_SIGN_PASSWORD }}"
|
||||
notarize:
|
||||
issuer_id: "{{ .Env.MACOS_NOTARY_ISSUER_ID }}"
|
||||
key_id: "{{ .Env.MACOS_NOTARY_KEY_ID }}"
|
||||
key: "{{ .Env.MACOS_NOTARY_KEY }}"
|
||||
wait: true
|
||||
timeout: 20m
|
||||
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
announce:
|
||||
discord:
|
||||
enabled: true
|
||||
message_template: "Pocket ID version {{ .Tag }} has been released! Checkout the changelog at {{ .ReleaseURL }}"
|
||||
author: "GitHub Actions"
|
||||
# Color code of the embed. You have to use decimal numeral system, not hexadecimal.
|
||||
# color: "9320959"
|
||||
icon_url: "https://github.githubassets.com/images/modules/logos_page/GitHub-Mark.png"
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: pocket-id
|
||||
name: pocket-id
|
||||
disable: '{{ if index .Env "SKIP_RELEASE" }}true{{ end }}'
|
||||
draft: true
|
||||
replace_existing_draft: false
|
||||
use_existing_draft: true
|
||||
replace_existing_artifacts: true
|
||||
prerelease: auto
|
||||
make_latest: true
|
||||
mode: keep-existing
|
||||
name_template: "{{ .Tag }}"
|
||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"inlang.vs-code-extension"
|
||||
"golang.go",
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
6
.vscode/launch.json
vendored
6
.vscode/launch.json
vendored
@@ -5,18 +5,20 @@
|
||||
"name": "Backend",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/backend/.env.example",
|
||||
"envFile": "${workspaceFolder}/backend/.env",
|
||||
"env": {
|
||||
"APP_ENV": "development"
|
||||
},
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/backend/cmd/main.go",
|
||||
"buildFlags": "-tags=exclude_frontend",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
},
|
||||
{
|
||||
"name": "Frontend",
|
||||
"type": "node",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/frontend/.env.example",
|
||||
"envFile": "${workspaceFolder}/frontend/.env",
|
||||
"cwd": "${workspaceFolder}/frontend",
|
||||
"runtimeExecutable": "npm",
|
||||
"runtimeArgs": [
|
||||
|
||||
5
.vscode/settings.json
vendored
5
.vscode/settings.json
vendored
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"go.buildTags": "e2etest"
|
||||
}
|
||||
"go.buildTags": "e2etest unit",
|
||||
"prettier.documentSelectors": ["**/*.svelte"]
|
||||
}
|
||||
|
||||
37
.vscode/tasks.json
vendored
37
.vscode/tasks.json
vendored
@@ -1,37 +0,0 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run Caddy",
|
||||
"type": "shell",
|
||||
"command": "caddy run --config reverse-proxy/Caddyfile",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "custom",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".*",
|
||||
"endsPattern": "Caddyfile.*"
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen",
|
||||
"instanceLimit": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
102
AGENTS.md
Normal file
102
AGENTS.md
Normal file
@@ -0,0 +1,102 @@
|
||||
# AGENTS.md
|
||||
|
||||
Pocket ID — a passkey-only OIDC provider. Go backend serves a SvelteKit SPA (embedded in
|
||||
the binary for production). This file lists what isn't obvious from reading the code.
|
||||
|
||||
## Layout
|
||||
|
||||
- `backend/` — Go module (gin, GORM, [ory/fosite fork](#backend-go)). Its own toolchain, not part of the repository.
|
||||
- `frontend/` — SvelteKit 5 SPA. Builds into `backend/frontend/dist` and is embedded via `go:embed`.
|
||||
- `tests/` — Playwright end-to-end tests (drives a Dockerized full stack).
|
||||
|
||||
## Build / test / lint
|
||||
|
||||
```sh
|
||||
# backend/ — the exclude_frontend and unit tags are mandatory locally; CI uses them too
|
||||
go test -tags=exclude_frontend,unit ./... # unit/integration tests
|
||||
go test -tags=exclude_frontend,unit -run TestName ./internal/... # a single test
|
||||
golangci-lint run # lint (config: backend/.golangci.yml - includes build tags)
|
||||
|
||||
# frontend/ (or root)
|
||||
pnpm check # svelte-check — the ONLY frontend type gate (no unit tests exist)
|
||||
pnpm lint # prettier --check && eslint (note: not enforced by CI)
|
||||
pnpm format # prettier --write — REQUIRED before opening a PR
|
||||
```
|
||||
|
||||
End-to-end (needs Docker; **stop any local backend on `:1411` first** — see gotchas):
|
||||
|
||||
```sh
|
||||
cd tests/setup && docker compose up -d --build # rebuild after ANY code change, or you test stale code
|
||||
cd ../.. && pnpm test # = playwright test in tests/
|
||||
```
|
||||
|
||||
## Critical gotchas
|
||||
|
||||
- **`exclude_frontend` and `unit` build tags.** Without them plain `go run`/`go test`/`golangci-lint` fail
|
||||
to run. Always pass `-tags exclude_frontend,unit` for backend dev/test/lint.
|
||||
- **Never edit generated files:** `frontend/src/lib/paraglide/**` (Paraglide i18n output) and
|
||||
`backend/frontend/dist/**`. For i18n, only edit `frontend/messages/en.json`; other locales come
|
||||
from Crowdin.
|
||||
- **Migrations are split by DB.** Raw SQL via golang-migrate in
|
||||
`backend/resources/migrations/{sqlite,postgres}/` — separate files _and_ separate version
|
||||
timelines. Add a matching up/down pair to **both**. Not GORM AutoMigrate. SQLite migrations
|
||||
are not auto-wrapped in a transaction (`NoTxWrap`); wrap multi-statement ones manually
|
||||
(`PRAGMA foreign_keys=OFF; BEGIN; … COMMIT; PRAGMA foreign_keys=ON;`).
|
||||
|
||||
## Backend (Go)
|
||||
|
||||
- **Config:** global `common.EnvConfig` (caarlos0/env); any secret var supports a `*_FILE` variant.
|
||||
- **Logging:** stdlib `log/slog` only (bridged to OpenTelemetry). No zerolog/logrus in app code.
|
||||
- `go.mod` pins a **fork** of fosite (`replace github.com/ory/fosite => github.com/pocket-id/fosite`).
|
||||
|
||||
## Frontend (SvelteKit)
|
||||
|
||||
- **Svelte 5 runes only:** `$state`, `$derived`, `$props`, `$bindable`. No `export let`. Event
|
||||
modifiers are gone — use `preventDefault` from `$lib/utils/event-util` (`onsubmit={preventDefault(fn)}`).
|
||||
- **Forms:** use the custom `createForm(schema, initial)` from `$lib/utils/form-util.ts` with
|
||||
`form-input.svelte`. The vendored shadcn formsnap/superforms wrappers exist but app forms don't
|
||||
use them — match the surrounding file. Import zod as `import { z } from 'zod/v4'`.
|
||||
|
||||
## Coding Style Guidelines
|
||||
|
||||
### Comments
|
||||
|
||||
- Exactly one sentence per line
|
||||
- There is NO maximum line width: never wrap a single sentence across multiple comment lines, no matter how long that sentence is
|
||||
- A new line in a comment means a new sentence; a wrapped line does not exist
|
||||
- No trailing period on single-line comments
|
||||
- Prefer comments that explain intent, invariants, or why a branch exists
|
||||
- Avoid comments that simply restate the next line of code
|
||||
- For multi-step logic, use short section comments to separate the steps and explain why each step exists
|
||||
- Inside a function, put a one-sentence comment above each major action; the comments double as visual separators between sections and should say what the step does and why, not how
|
||||
- Favor a few well-placed section comments over a wall of code; a reader should be able to skim the comments and understand the method's flow
|
||||
|
||||
```go
|
||||
// Wrong — one sentence wrapped across multiple lines
|
||||
// This function performs the main validation logic. It checks
|
||||
// the input against the schema and returns an error if the
|
||||
// input is invalid.
|
||||
|
||||
// Wrong — trailing period on single-line comment
|
||||
// Validate the input.
|
||||
|
||||
// Right — one sentence per line, each line as long as it needs to be
|
||||
// This function performs the main validation logic
|
||||
// It checks the input against the schema and returns an error if the input is invalid
|
||||
|
||||
// Right
|
||||
// Validate the input
|
||||
|
||||
// Right
|
||||
// Normalize the request host so callers can pass either Host or X-Forwarded-Host values
|
||||
|
||||
// Right
|
||||
// Browsers do not accept a cookie Domain attribute set to an IP address
|
||||
// Returning an empty domain tells the caller to set a host-only cookie instead
|
||||
|
||||
// Wrong — restates the code
|
||||
// Trim whitespace and lowercase the host
|
||||
host = strings.TrimSpace(strings.ToLower(host))
|
||||
```
|
||||
|
||||
Section comments inside a function — one sentence per major action, describing what and why, acting as visual separators:
|
||||
2468
CHANGELOG.md
2468
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
120
CONTRIBUTING.md
120
CONTRIBUTING.md
@@ -1,12 +1,35 @@
|
||||
# Contributing
|
||||
|
||||
I am happy that you want to contribute to Pocket ID and help to make it better! All contributions are welcome, including issues, suggestions, pull requests and more.
|
||||
We are happy that you want to contribute to Pocket ID and help to make it better!
|
||||
|
||||
## Before you start
|
||||
|
||||
Before starting to work on a new feature, please open an issue first, or comment on an existing one, to discuss the implementation details. This saves you time and avoids the disappointment of having a PR closed later because the change doesn't fit the project's direction.
|
||||
|
||||
### AI Usage Policy
|
||||
|
||||
We have nothing against using AI tools to assist your development. But we've seen a growing number of pull requests that appear to be fully or largely AI-generated and submitted without genuine human review. These are difficult and time-consuming for maintainers to review, and they often waste everyone's time.
|
||||
|
||||
To keep contributions reviewable and high-quality, please follow these guidelines when using AI tools.
|
||||
|
||||
#### Guidelines for Using AI Tools
|
||||
|
||||
1. **Understand every line.** You must be able to explain what your code does and why, in your own words. "The AI wrote it" is not an acceptable answer to a reviewer's question.
|
||||
2. **Test before submitting.** Review and test all code manually, as a human, before opening a PR. Don't trust the AI's claim that it works.
|
||||
3. **Write your own words.** Don't paste AI-generated text into issues, comments, or PR descriptions. Walls of generated text make discussions harder, not easier.
|
||||
4. **Disclose your usage.** Note in the PR description how you used AI (see below).
|
||||
|
||||
PRs that appear to be low effort AI output may be closed without a detailed review.
|
||||
|
||||
#### Example disclosure
|
||||
|
||||
> Used GitHub Copilot for autocomplete, and asked an LLM to help draft the test cases in `parser_test.go`. I reviewed, edited, and tested everything myself and understand how it works.
|
||||
|
||||
> Used an LLM to generate the initial implementation of the new API endpoint, but I manually reviewed and tested it before submitting.
|
||||
|
||||
## Getting started
|
||||
|
||||
You've found a bug, have suggestion or something else, just create an issue on GitHub and we can get in touch.
|
||||
|
||||
## Submit a Pull Request
|
||||
### Submit a Pull Request
|
||||
|
||||
Before you submit the pull request for review please ensure that
|
||||
|
||||
@@ -17,68 +40,89 @@ Before you submit the pull request for review please ensure that
|
||||
example:
|
||||
|
||||
```
|
||||
feat(share): add password protection
|
||||
fix: hide global audit log switch for non admin users
|
||||
```
|
||||
|
||||
Where `TYPE` can be:
|
||||
|
||||
- **feat** - is a new feature
|
||||
- **doc** - documentation only changes
|
||||
- **fix** - a bug fix
|
||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||
|
||||
- Your pull request has a detailed description
|
||||
- You run `npm run format` to format the code
|
||||
- You run `pnpm format` to format the code
|
||||
|
||||
## Setup project
|
||||
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
|
||||
### Development Environment
|
||||
|
||||
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
|
||||
|
||||
There are two ways to get the development environment setup:
|
||||
|
||||
#### 1. Install required tools
|
||||
|
||||
##### With Dev Containers
|
||||
|
||||
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
|
||||
|
||||
## 1. Using DevContainers
|
||||
1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed
|
||||
2. Clone and open the repo in VS Code
|
||||
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
|
||||
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
|
||||
|
||||
## 2. Manual
|
||||
##### Without Dev Containers
|
||||
|
||||
### Backend
|
||||
If you don't use Dev Containers, you need to install the following tools manually:
|
||||
|
||||
The backend is built with [Gin](https://gin-gonic.com) and written in Go.
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 24
|
||||
- [Go](https://golang.org/doc/install) >= 1.26
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Setup
|
||||
#### 2. Setup
|
||||
|
||||
##### Backend
|
||||
|
||||
The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set it up, follow these steps:
|
||||
|
||||
1. Open the `backend` folder
|
||||
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
|
||||
3. Start the backend with `go run -tags e2etest ./cmd`
|
||||
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
|
||||
3. Start the backend with `go run -tags exclude_frontend ./cmd`
|
||||
|
||||
### Frontend
|
||||
##### Frontend
|
||||
|
||||
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript.
|
||||
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
|
||||
|
||||
#### Setup
|
||||
1. Open the `pocket-id` project folder
|
||||
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
|
||||
3. Install the dependencies with `pnpm install`
|
||||
4. Start the frontend with `pnpm dev`
|
||||
|
||||
1. Open the `frontend` folder
|
||||
2. Copy the `.env.example` file to `.env`
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
### Reverse Proxy
|
||||
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
|
||||
|
||||
#### Setup
|
||||
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
||||
|
||||
You're all set!
|
||||
|
||||
## Debugging
|
||||
1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json))
|
||||
2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json))
|
||||
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
|
||||
|
||||
### Testing
|
||||
|
||||
If you are contributing to a new feature please ensure that you add tests for it.
|
||||
|
||||
#### End-to-end tests
|
||||
|
||||
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
||||
|
||||
The tests are located in the `tests` folder at the root of the project.
|
||||
|
||||
The tests can be run like this:
|
||||
1. Start the backend normally
|
||||
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js`
|
||||
3. Run the tests with `npm run test`
|
||||
|
||||
1. Install the dependencies from the root of the project `pnpm install`
|
||||
|
||||
2. Visit the setup folder by running `cd tests/setup`
|
||||
|
||||
3. Start the test environment by running `docker compose up -d --build`
|
||||
|
||||
4. Go back to the test folder by running `cd ..`
|
||||
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
|
||||
|
||||
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
|
||||
|
||||
#### Unit tests
|
||||
|
||||
In the backend we are using unit tests with the built-in Go testing framework. The tests are located in the same folder as the code they are testing and have the `_test.go` suffix.
|
||||
|
||||
To run the tests, simply run `go test -tags=exclude_frontend,unit ./...` from the root of the `backend` folder.
|
||||
|
||||
53
Dockerfile
53
Dockerfile
@@ -1,53 +0,0 @@
|
||||
# Tags passed to "go build"
|
||||
ARG BUILD_TAGS=""
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY ./frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY ./frontend ./
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.23-alpine AS backend-builder
|
||||
ARG BUILD_TAGS
|
||||
WORKDIR /app/backend
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY ./backend ./
|
||||
WORKDIR /app/backend/cmd
|
||||
RUN CGO_ENABLED=1 \
|
||||
GOOS=linux \
|
||||
go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-o /app/backend/pocket-id-backend \
|
||||
.
|
||||
|
||||
# Stage 3: Production Image
|
||||
FROM node:22-alpine
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk add --no-cache caddy curl su-exec
|
||||
COPY ./reverse-proxy /etc/caddy/
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||
COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
|
||||
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
||||
|
||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||
|
||||
COPY ./scripts ./scripts
|
||||
RUN chmod +x ./scripts/**/*.sh
|
||||
|
||||
EXPOSE 80
|
||||
ENV APP_ENV=production
|
||||
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
10
README.md
10
README.md
@@ -1,15 +1,19 @@
|
||||
# <div align="center"><img src="https://github.com/user-attachments/assets/4ceb2708-9f29-4694-b797-be833efce17d" width="100"/> </br>Pocket ID</div>
|
||||
|
||||
Pocket ID is a simple OIDC provider that allows users to authenticate with their passkeys to your services.
|
||||
Pocket ID is an easy-to-use OpenID Connect Certified™ and OAuth 2.0 provider that lets users sign in to your applications with passkeys.
|
||||
|
||||
→ Try out the [Demo](https://demo.pocket-id.org)
|
||||
|
||||
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
|
||||
<img src="https://github.com/user-attachments/assets/1e99ba44-76da-4b47-9b8a-dbe9b7f84512" width="1200"/>
|
||||
|
||||
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
|
||||
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC and OAuth 2.0 providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.
|
||||
|
||||
Additionally, what makes Pocket ID special is that it only supports [passkey](https://www.passkeys.io/) authentication, which means you don’t need a password. Some people might not like this idea at first, but I believe passkeys are the future, and once you try them, you’ll love them. For example, you can now use a physical Yubikey to sign in to all your self-hosted services easily and securely.
|
||||
|
||||
<a href="https://openid.net/certification/certified-openid-providers-profiles">
|
||||
<img src="https://github.com/user-attachments/assets/697a8b8f-61bc-412f-8222-5410da7292c7" height="80" alt="OpenID Connect Certified">
|
||||
</a>
|
||||
|
||||
## Setup
|
||||
|
||||
Pocket ID can be set up in multiple ways. The easiest and recommended way is to use Docker.
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
|
||||
## Supported Versions
|
||||
|
||||
As Pocket ID is in its early stages, older versions don't get security updates. Please consider to update Pocket ID regularly. Updates can be automated with e.g [Watchtower](https://github.com/containrrr/watchtower).
|
||||
It's recommended to always use the latest version of Pocket ID. We will provide security updates for the latest version.
|
||||
|
||||
> [!NOTE]
|
||||
> Updates can be automated with e.g [Watchtower](https://github.com/nicholas-fedor/watchtower/). Upgrading between non major versions is safe but you shouldn't upgrade between major versions before checking the release notes.
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please write an email to [elias@eliasschneider.com](mailto:elias@eliasschneider.com).
|
||||
Thank you for taking the time to report a vulnerability. Please DO NOT create an issue on GitHub because the vulnerability could get exploited. Instead please create a Security Advisory [here](https://github.com/pocket-id/pocket-id/security/advisories/new). You can learn more about how to report a vulnerability in the [GitHub Security Advisories documentation](https://docs.github.com/en/code-security/security-advisories/creating-a-security-advisory).
|
||||
|
||||
12
backend/.air.toml
Normal file
12
backend/.air.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
root = "."
|
||||
tmp_dir = ".bin"
|
||||
|
||||
[build]
|
||||
bin = "./.bin/pocket-id"
|
||||
cmd = "CGO_ENABLED=0 go build -o ./.bin/pocket-id ./cmd"
|
||||
exclude_dir = ["resources", ".bin", "data"]
|
||||
exclude_regex = [".*_test\\.go"]
|
||||
stop_on_error = true
|
||||
|
||||
[misc]
|
||||
clean_on_exit = true
|
||||
12
backend/.env.development-example
Normal file
12
backend/.env.development-example
Normal file
@@ -0,0 +1,12 @@
|
||||
# Sample .env file for development
|
||||
# All environment variables can be found on https://pocket-id.org/docs/configuration/environment-variables
|
||||
|
||||
APP_ENV=development
|
||||
# Set the APP_URL to the URL where the frontend is listening
|
||||
# In the development environment the backend gets proxied by the frontend
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=1411
|
||||
MAXMIND_LICENSE_KEY=your_license_key
|
||||
|
||||
# Set the ENCRYPTION_KEY to a random sequence of characters, for example generated with `openssl rand -base64 32`
|
||||
ENCRYPTION_KEY=
|
||||
@@ -1,10 +0,0 @@
|
||||
APP_ENV=production
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
|
||||
DB_PROVIDER=sqlite
|
||||
# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log
|
||||
SQLITE_DB_PATH=data/pocket-id.db
|
||||
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
||||
UPLOAD_PATH=data/uploads
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -15,3 +15,4 @@
|
||||
# vendor/
|
||||
./data
|
||||
.env
|
||||
pocket-id
|
||||
@@ -2,6 +2,9 @@ version: "2"
|
||||
run:
|
||||
tests: true
|
||||
timeout: 5m
|
||||
build-tags:
|
||||
- unit
|
||||
- exclude_frontend
|
||||
linters:
|
||||
default: none
|
||||
enable:
|
||||
@@ -61,4 +64,4 @@ formatters:
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- examples$
|
||||
11
backend/Makefile
Normal file
11
backend/Makefile
Normal file
@@ -0,0 +1,11 @@
|
||||
.PHONY: test
|
||||
test:
|
||||
go test -tags exclude_frontend,unit ./...
|
||||
|
||||
.PHONY: test-race
|
||||
test-race:
|
||||
CGO_ENABLED=1 go test -race -tags exclude_frontend,unit ./...
|
||||
|
||||
.PHONY: lint
|
||||
lint:
|
||||
golangci-lint run -c .golangci.yml
|
||||
@@ -1,9 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"fmt"
|
||||
"os"
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/cmds"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// @title Pocket ID API
|
||||
// @version 1.0
|
||||
// @description.markdown
|
||||
|
||||
func main() {
|
||||
bootstrap.Bootstrap()
|
||||
if err := common.ValidateEnvConfig(&common.EnvConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmds.Execute()
|
||||
}
|
||||
|
||||
11
backend/frontend/frontend_excluded.go
Normal file
11
backend/frontend/frontend_excluded.go
Normal file
@@ -0,0 +1,11 @@
|
||||
//go:build exclude_frontend
|
||||
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
return ErrFrontendNotIncluded
|
||||
}
|
||||
258
backend/frontend/frontend_included.go
Normal file
258
backend/frontend/frontend_included.go
Normal file
@@ -0,0 +1,258 @@
|
||||
//go:build !exclude_frontend
|
||||
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
)
|
||||
|
||||
//go:embed all:dist/*
|
||||
var frontendFS embed.FS
|
||||
|
||||
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
|
||||
var writeIndexFn func(w io.Writer, nonce string) error
|
||||
|
||||
func init() {
|
||||
const scriptTag = "<script>"
|
||||
|
||||
// Read the index.html from the bundle
|
||||
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
|
||||
if iErr != nil {
|
||||
panic(fmt.Errorf("failed to read index.html: %w", iErr))
|
||||
}
|
||||
|
||||
writeIndexFn = func(w io.Writer, nonce string) (err error) {
|
||||
// If there's no nonce, write the index as-is
|
||||
if nonce == "" {
|
||||
_, err = w.Write(index)
|
||||
return err
|
||||
}
|
||||
|
||||
// Add nonce to all <script> tags
|
||||
// We replace "<script" with `<script nonce="..."` everywhere it appears
|
||||
modified := bytes.ReplaceAll(
|
||||
index,
|
||||
[]byte(scriptTag),
|
||||
[]byte(`<script nonce="`+nonce+`">`),
|
||||
)
|
||||
|
||||
_, err = w.Write(modified)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||
}
|
||||
|
||||
// Load a map of all files to see which ones are available pre-compressed
|
||||
preCompressed, err := listPreCompressedAssets(distFS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
|
||||
}
|
||||
|
||||
// Init the file server
|
||||
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
|
||||
|
||||
// Handler for Gin
|
||||
handler := func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
if strings.HasSuffix(path, "/") {
|
||||
c.Redirect(http.StatusMovedPermanently, strings.TrimRight(c.Request.URL.String(), "/"))
|
||||
return
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "api/") || strings.HasPrefix(path, ".well-known/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
|
||||
return
|
||||
}
|
||||
|
||||
if isSPARequest(path, distFS) {
|
||||
nonce := middleware.GetCSPNonce(c)
|
||||
|
||||
// Do not cache the HTML shell, as it embeds a per-request nonce
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Status(http.StatusOK)
|
||||
if err := writeIndexFn(c.Writer, nonce); err != nil {
|
||||
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Serve other static assets with caching
|
||||
c.Request.URL.Path = "/" + path
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
}
|
||||
|
||||
router.NoRoute(handler)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func isSPARequest(path string, distFS fs.FS) bool {
|
||||
if path == "" {
|
||||
return true
|
||||
}
|
||||
|
||||
if _, err := fs.Stat(distFS, path); err != nil {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// FileServerWithCaching wraps http.FileServer to add caching headers
|
||||
type FileServerWithCaching struct {
|
||||
root http.FileSystem
|
||||
lastModified time.Time
|
||||
lastModifiedHeaderValue string
|
||||
preCompressed preCompressedMap
|
||||
}
|
||||
|
||||
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching {
|
||||
return &FileServerWithCaching{
|
||||
root: root,
|
||||
lastModified: time.Now(),
|
||||
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
|
||||
preCompressed: preCompressed,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// First, set cache headers
|
||||
// Check if the request is for an immutable asset
|
||||
if isImmutableAsset(r) {
|
||||
// Set the cache control header as immutable with a long expiration
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
// Check if the client has a cached version
|
||||
ifModifiedSince := r.Header.Get("If-Modified-Since")
|
||||
if ifModifiedSince != "" {
|
||||
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
||||
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
|
||||
// Client's cached version is up to date
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache other assets for up to 24 hours, but set Last-Modified too
|
||||
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
}
|
||||
|
||||
// Check if the asset is available pre-compressed
|
||||
_, ok := f.preCompressed[r.URL.Path]
|
||||
if ok {
|
||||
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
|
||||
// Select the encoding if any
|
||||
ext, ce := f.selectEncoding(r)
|
||||
if ext != "" {
|
||||
// Set the content type explicitly before changing the path
|
||||
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
|
||||
if ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
|
||||
// Make the serve return the encoded content
|
||||
w.Header().Set("Content-Encoding", ce)
|
||||
r.URL.Path += "." + ext
|
||||
}
|
||||
}
|
||||
|
||||
http.FileServer(f.root).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (f *FileServerWithCaching) selectEncoding(r *http.Request) (ext string, contentEnc string) {
|
||||
available, ok := f.preCompressed[r.URL.Path]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Check if the client accepts compressed files
|
||||
acceptEncoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Accept-Encoding")))
|
||||
if acceptEncoding == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Prefer brotli over gzip when both are accepted.
|
||||
if available.br && (acceptEncoding == "*" || acceptEncoding == "br" || strings.Contains(acceptEncoding, "br")) {
|
||||
return "br", "br"
|
||||
}
|
||||
if available.gz && (acceptEncoding == "gzip" || strings.Contains(acceptEncoding, "gzip")) {
|
||||
return "gz", "gzip"
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func isImmutableAsset(r *http.Request) bool {
|
||||
switch {
|
||||
// Fonts
|
||||
case strings.HasPrefix(r.URL.Path, "/fonts/"):
|
||||
return true
|
||||
|
||||
// Compiled SvelteKit assets
|
||||
case strings.HasPrefix(r.URL.Path, "/_app/immutable/"):
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type preCompressedMap map[string]struct {
|
||||
br bool
|
||||
gz bool
|
||||
}
|
||||
|
||||
func listPreCompressedAssets(distFS fs.FS) (preCompressedMap, error) {
|
||||
preCompressed := make(preCompressedMap, 0)
|
||||
err := fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".br"):
|
||||
originalPath := "/" + strings.TrimSuffix(path, ".br")
|
||||
entry := preCompressed[originalPath]
|
||||
entry.br = true
|
||||
preCompressed[originalPath] = entry
|
||||
case strings.HasSuffix(path, ".gz"):
|
||||
originalPath := "/" + strings.TrimSuffix(path, ".gz")
|
||||
entry := preCompressed[originalPath]
|
||||
entry.gz = true
|
||||
preCompressed[originalPath] = entry
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preCompressed, nil
|
||||
}
|
||||
28
backend/frontend/frontend_included_test.go
Normal file
28
backend/frontend/frontend_included_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
//go:build !exclude_frontend
|
||||
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestIsSPARequest(t *testing.T) {
|
||||
distFS := fstest.MapFS{
|
||||
"assets/app.js": &fstest.MapFile{Data: []byte("console.log('test')")},
|
||||
}
|
||||
|
||||
t.Run("root path is spa request", func(t *testing.T) {
|
||||
assert.True(t, isSPARequest("", distFS))
|
||||
})
|
||||
|
||||
t.Run("existing bundled asset is not spa request", func(t *testing.T) {
|
||||
assert.False(t, isSPARequest("assets/app.js", distFS))
|
||||
})
|
||||
|
||||
t.Run("unknown path is spa request", func(t *testing.T) {
|
||||
assert.True(t, isSPARequest("authorize", distFS))
|
||||
})
|
||||
}
|
||||
5
backend/frontend/shared.go
Normal file
5
backend/frontend/shared.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package frontend
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrFrontendNotIncluded = errors.New("frontend is not included")
|
||||
278
backend/go.mod
278
backend/go.mod
@@ -1,88 +1,250 @@
|
||||
module github.com/pocket-id/pocket-id/backend
|
||||
|
||||
go 1.23.7
|
||||
go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
|
||||
github.com/aws/smithy-go v1.27.3
|
||||
github.com/caarlos0/env/v11 v11.4.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.21.3
|
||||
github.com/fxamacker/cbor/v2 v2.7.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.15.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.10
|
||||
github.com/go-playground/validator/v10 v10.24.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-contrib/slog v1.2.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
github.com/go-webauthn/webauthn v0.17.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/italypaleale/francis v0.1.0-beta.10
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be
|
||||
github.com/italypaleale/go-sql-utils v0.2.4
|
||||
github.com/jackc/pgx/v5 v5.10.0
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/libtnb/sqlite v1.1.2
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||
github.com/stretchr/testify v1.10.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/time v0.9.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.4.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/zitadel/exifremove v0.1.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0
|
||||
go.opentelemetry.io/otel v1.44.0
|
||||
go.opentelemetry.io/otel/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk v1.44.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/text v0.39.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
modernc.org/sqlite v1.53.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/bytedance/sonic v1.12.8 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/disintegration/gift v1.1.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
filippo.io/edwards25519 v1.2.0 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.1 // indirect
|
||||
github.com/ClickHouse/ch-go v0.61.5 // indirect
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 // indirect
|
||||
github.com/alphadose/haxmap v1.4.1 // indirect
|
||||
github.com/andybalholm/brotli v1.1.1 // indirect
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/cristalhq/jwt/v5 v5.4.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
|
||||
github.com/disintegration/gift v1.2.1 // indirect
|
||||
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d // indirect
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d // indirect
|
||||
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413 // indirect
|
||||
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102 // indirect
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd // indirect
|
||||
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d // indirect
|
||||
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect
|
||||
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect
|
||||
github.com/dunglas/httpsfv v1.1.0 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.1.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-errors/errors v1.5.1 // indirect
|
||||
github.com/go-faster/city v1.0.1 // indirect
|
||||
github.com/go-faster/errors v0.7.1 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.16 // indirect
|
||||
github.com/goccy/go-json v0.10.4 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/go-sql-driver/mysql v1.9.3 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.6 // indirect
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/gogo/googleapis v1.4.1 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 // indirect
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
|
||||
github.com/h2non/filetype v1.1.3 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-version v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jaegertracing/jaeger-idl v0.9.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
||||
github.com/kr/pretty v0.3.1 // indirect
|
||||
github.com/klauspost/compress v1.18.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.2.1 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||
github.com/mattn/goveralls v0.0.12 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||
github.com/openzipkin/zipkin-go v0.4.3 // indirect
|
||||
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe // indirect
|
||||
github.com/ory/go-convenience v0.1.0 // indirect
|
||||
github.com/ory/pop/v6 v6.4.1 // indirect
|
||||
github.com/ory/x v0.0.729 // indirect
|
||||
github.com/paulmach/orb v0.11.1 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.21 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.69.0 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.21.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.60.0 // indirect
|
||||
github.com/quic-go/webtransport-go v0.11.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 // indirect
|
||||
github.com/segmentio/asm v1.2.1 // indirect
|
||||
github.com/shopspring/decimal v1.4.0 // indirect
|
||||
github.com/sirupsen/logrus v1.9.4 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/spf13/viper v1.21.0 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/tinylib/msgp v1.6.4 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.13.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/net v0.36.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
google.golang.org/protobuf v1.36.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 // indirect
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 // indirect
|
||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.44.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.44.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.uber.org/mock v0.6.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/arch v0.27.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
google.golang.org/grpc v1.82.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
gorm.io/driver/clickhouse v0.7.0 // indirect
|
||||
gorm.io/driver/mysql v1.5.7 // indirect
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect
|
||||
modernc.org/libc v1.73.5 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6
|
||||
|
||||
786
backend/go.sum
786
backend/go.sum
@@ -1,65 +1,184 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
|
||||
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.1.1 h1:l+FM/EEMb0U9QZE7mKNEDw5Mu3mFiaa2GKOoTSsNDPw=
|
||||
github.com/Azure/go-ntlmssp v0.1.1/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
|
||||
github.com/ClickHouse/ch-go v0.61.5 h1:zwR8QbYI0tsMiEcze/uIMK+Tz1D3XZXLdNrlaOpeEI4=
|
||||
github.com/ClickHouse/ch-go v0.61.5/go.mod h1:s1LJW/F/LcFs5HJnuogFMta50kKDO0lf9zzfrbl0RQg=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0 h1:AG4D/hW39qa58+JHQIFOSnxyL46H6h2lrmGGk17dhFo=
|
||||
github.com/ClickHouse/clickhouse-go/v2 v2.30.0/go.mod h1:i9ZQAojcayW3RsdCb3YR+n+wC2h65eJsZCscZ1Z1wyo=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
|
||||
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q=
|
||||
github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM=
|
||||
github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA=
|
||||
github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
|
||||
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K0ek=
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30/go.mod h1:WueJeNDZvK1fMYEWJIkcivBfEzUkTpBhzlrUKKY8EuA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30 h1:jn46zC9LdsVR/ZpMIJqMqb8hHv31BlLx3ulVqNspUOk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.30/go.mod h1:1hTMsAgbdS/AtUi4bw8+gUuh1pceo+eXRLfpSuSQj3M=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31/go.mod h1:7PuV1yl5e2xnUbm+RqvVg5i2iBM8EyijZNoI9wsOoOc=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13 h1:mbRIur/BiHK6SKPjoBIXSE/hJ6g6JGRLuxQy1jGjlN4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.13/go.mod h1:ITg9em2KbJx1s0y4aqRX5OYWG6HBZ5TVR//OdpEZ2CQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23 h1:9Fjh6fi/U5JEStVZijmaMpUwE/gvBJj7x2B/PjbO9To=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.23/go.mod h1:iMoT2f1tClxrWAAnKCXjZQ6LOmfLrMG14wmnWpM+F14=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrKsSD7EmDjzAPsm/3L9IuOkzaynklJZ1qX7S4=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0/go.mod h1:u8af9Nqkmqnr96f7v9nHqzZT9XBwbXEkTiqT4ROuJSE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0 h1:fpOlDPI55HdszaxapEGk6HsGosOUaM2YPWJpjMgp8UI=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.37.0/go.mod h1:DMPWJBjYs6+3+f/qhBFEFPPlQ6NlhWjai3dJNvipJ84=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0 h1:bLZ0PolJ8J+HkJHztcXORUpHXBye2U8298lCEMi6ZCU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.44.0/go.mod h1:9gdl4RrflIdpDb2TlXshWgR1F9TeCkvqDx77Vpr4Z/Q=
|
||||
github.com/aws/smithy-go v1.27.3 h1:F3Zb497UhhskkfpJmfkXswyo+t0sh9OTBnIHjogWbVY=
|
||||
github.com/aws/smithy-go v1.27.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw=
|
||||
github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw=
|
||||
github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU=
|
||||
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cristalhq/jwt/v5 v5.4.0 h1:Wxi1TocFHaijyV608j7v7B9mPc4ZNjvWT3LKBO0d4QI=
|
||||
github.com/cristalhq/jwt/v5 v5.4.0/go.mod h1:+b/BzaCWEpFDmXxspJ5h4SdJ1N/45KMjKOetWzmHvDA=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
|
||||
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
|
||||
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
|
||||
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
|
||||
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
|
||||
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
|
||||
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
|
||||
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
|
||||
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d h1:ygcRCGNKuEiA98k7X35hknEN8RIRUF1jrz7k1rZCvsk=
|
||||
github.com/dsoprea/go-exif v0.0.0-20230826092837-6579e82b732d/go.mod h1:lOaOt7+UEppOgyvRy749v3do836U/hw0YVJNjoyPaEs=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20200321225314-640175a69fe4/go.mod h1:Lm2lMM2zx8p4a34ZemkaUV95AnMl4ZvLbCUbwOvLC2E=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20200604193436-ca8584a0e1c4/go.mod h1:9EXlPeHfblFFnwu5UOqmP2eoZfJyAZ2Ri/Vki33ajO0=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d h1:yeH8wrJa3+8uKKDAdURHUK1ds2UvKhMqX2MiOdVeKPs=
|
||||
github.com/dsoprea/go-exif/v2 v2.0.0-20230826092837-6579e82b732d/go.mod h1:oKrjk2kb3rAR5NbtSTLUMvMSbc+k8ZosI3MaVH47noc=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20200717053412-08f1b6708903/go.mod h1:0nsO1ce0mh5czxGeLo4+OCZ/C6Eo6ZlMWsz7rH/Gxv8=
|
||||
github.com/dsoprea/go-exif/v3 v3.0.0-20210512043655-120bcdb2a55e/go.mod h1:cg5SNYKHMmzxsr9X6ZeLh/nfBRHHp5PngtEPcujONtk=
|
||||
github.com/dsoprea/go-iptc v0.0.0-20200609062250-162ae6b44feb/go.mod h1:kYIdx9N9NaOyD7U6D+YtExN7QhRm+5kq7//yOsRXQtM=
|
||||
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413 h1:YDRiMEm32T60Kpm35YzOK9ZHgjsS1Qrid+XskNcsdp8=
|
||||
github.com/dsoprea/go-iptc v0.0.0-20200610044640-bc9ca208b413/go.mod h1:kYIdx9N9NaOyD7U6D+YtExN7QhRm+5kq7//yOsRXQtM=
|
||||
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102 h1:P1dsxzctGkmG6Zf7gH2xrZhNXWP5/FuLDI7xbCGsWTo=
|
||||
github.com/dsoprea/go-jpeg-image-structure v0.0.0-20221012074422-4f3f7e934102/go.mod h1:6+tQXZ+I62x13UZ+hemLVoZIuq/usVzvau7bqwUo9P0=
|
||||
github.com/dsoprea/go-logging v0.0.0-20190624164917-c4f10aab7696/go.mod h1:Nm/x2ZUNRW6Fe5C3LxdY1PyZY5wmDv/s5dkPJ/VB3iA=
|
||||
github.com/dsoprea/go-logging v0.0.0-20200517223158-a10564966e9d/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd h1:l+vLbuxptsC6VQyQsfD7NnEC8BZuFpz45PgY+pH8YTg=
|
||||
github.com/dsoprea/go-logging v0.0.0-20200710184922-b02d349568dd/go.mod h1:7I+3Pe2o/YSU88W0hWlm9S22W7XI1JFNJ86U0zPKMf8=
|
||||
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200609050348-3db9b63b202c/go.mod h1:pqKB+ijp27cEcrHxhXVgUUMlSDRuGJJp1E+20Lj5H0E=
|
||||
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d h1:dg6UMHa50VI01WuPWXPbNJpO8QSyvIF5T5n2IZiqX3A=
|
||||
github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d/go.mod h1:pqKB+ijp27cEcrHxhXVgUUMlSDRuGJJp1E+20Lj5H0E=
|
||||
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d h1:8+qI8ant/vZkNSsbwSjIR6XJfWcDVTg/qx/3pRUUZNA=
|
||||
github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d/go.mod h1:yTR3tKgyk20phAFg6IE9ulMA5NjEDD2wyx+okRFLVtw=
|
||||
github.com/dsoprea/go-utility v0.0.0-20200711062821-fab8125e9bdf/go.mod h1:95+K3z2L0mqsVYd6yveIv1lmtT3tcQQ3dVakPySffW8=
|
||||
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 h1:/py11NlxDaOxkT9OKN+gXgT+QOH5xj1ZRoyusfRIlo4=
|
||||
github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349/go.mod h1:KVK+/Hul09ujXAGq+42UBgCTnXkiJZRnLYdURGjQUwo=
|
||||
github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU=
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s=
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI=
|
||||
github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54=
|
||||
github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w=
|
||||
github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE=
|
||||
github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc=
|
||||
github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
|
||||
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/slog v1.2.1 h1:tQbsmllW/PNgtvHRdVlI38jLfpN4IFLS7Pb4HgTeiYw=
|
||||
github.com/gin-contrib/slog v1.2.1/go.mod h1:f/Ke0A3h4DUh0cQnjR2b/l+i0EmVJ+6VY6GIw3RKtxA=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk=
|
||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
|
||||
github.com/go-faster/city v1.0.1/go.mod h1:jKcUJId49qdW3L1qKHH/3wPeUstCVpVSXTM6vO3VcTw=
|
||||
github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AYg=
|
||||
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA=
|
||||
github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -68,45 +187,100 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
|
||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
|
||||
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
|
||||
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
|
||||
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
|
||||
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
|
||||
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
|
||||
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
|
||||
github.com/go-xmlfmt/xmlfmt v0.0.0-20191208150333-d5b6f63a941b/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3 h1:t8Ey3Uy7jDSEisW2K3somuMKIpzktkWptA0iFCnRUWY=
|
||||
github.com/go-xmlfmt/xmlfmt v1.1.3/go.mod h1:aUCEOzzezBEjDBbFBoSiya/gduyIiWYRP6CnSFIV8AM=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/googleapis v1.4.1 h1:1Yx4Myt7BxzvUr5ldGSbwYiZG6t9wGBZ+8/fX3Wvtq0=
|
||||
github.com/gogo/googleapis v1.4.1/go.mod h1:2lpHqI5OcWCtVElxXnPt+s8oJvMpySlOyM6xDCrzib4=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/golang/geo v0.0.0-20190916061304-5b978397cfec/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20200319012246-673a6f80352d/go.mod h1:QZ0nwyI2jOfgRAoBvP+ab5aRr7c9x7lhGEJrKvBwjWI=
|
||||
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7 h1:kG/6mhO8OwbQrA/0XEPwKJs3D3jG0m1rNH/ZRKDA/pQ=
|
||||
github.com/golang/geo v0.0.0-20250319145452-ed1c8b99c3d7/go.mod h1:J+F9/3Ofc8ysEOY2/cNjxTMl2eB1gvPIywEHUplPgDA=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
|
||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
|
||||
github.com/h2non/filetype v1.1.3 h1:FKkx9QbD7HR/zjK1Ia5XiBsq9zdLi5Kf3zGyFTAFkGg=
|
||||
github.com/h2non/filetype v1.1.3/go.mod h1:319b3zT68BvV+WRj7cwy856M2ehB3HqNOt6sy1HndBY=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/go-version v1.6.0 h1:feTTfFNnjP967rlCxM/I9g701jU+RN74YKx2mOkIeek=
|
||||
github.com/hashicorp/go-version v1.6.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/italypaleale/francis v0.1.0-beta.10 h1:LCYVwkZAkakv7g5ZS6TVIRH7hozr1A0eg24LXWWBJYE=
|
||||
github.com/italypaleale/francis v0.1.0-beta.10/go.mod h1:vqKhwdLs5Sx+n6JCNknEKAODtEU51E9/LC1q9JAG3zk=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be h1:jgu+Mdsda++LqPxz8cj8vvgiFINQ8PhFB4Q1VZpyPjs=
|
||||
github.com/italypaleale/go-kit v0.0.0-20260708054611-e276b65dd3be/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4 h1:6CN8y3qEdNzvYlS/JK6N65E8cL9F8a6OBCJjzaQIv3c=
|
||||
github.com/italypaleale/go-sql-utils v0.2.4/go.mod h1:BJStxMfB6fzYVcOe0oZQCjGIPZQu76UBmg1Wuy6Z/7I=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ=
|
||||
github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
|
||||
github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jaegertracing/jaeger-idl v0.9.0 h1:dI4olA7ArW3cjXwVbic/aYKDbdlfe7V+9wPQqAdzu8Y=
|
||||
github.com/jaegertracing/jaeger-idl v0.9.0/go.mod h1:W+9vbcr2cVZyS6z/cbr540EOzSkKYml3hmaWEavxkB0=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
|
||||
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
|
||||
@@ -119,6 +293,9 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -129,168 +306,402 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
|
||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/klauspost/compress v1.13.6/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
|
||||
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
|
||||
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=
|
||||
github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=
|
||||
github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU=
|
||||
github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY=
|
||||
github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME=
|
||||
github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c=
|
||||
github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4=
|
||||
github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
|
||||
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6 h1:4FpLQ18KK/ypPbVU3NLWJNRvH3kcYiqKqWfKGqNWxxI=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1 h1:yd9AdPmZ4INnQ7k42IrzXYpnEG803+SrQ6hdMvzHJzw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1/go.mod h1:uw/MN2M/Xiu4FhwcIwH11Zsh9JWx9SWzgALl7/uIEkU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libtnb/sqlite v1.1.2 h1:3g8nez8MwdEhn+oqt3bfKWnYJxk0ZGTO0kVA1WT+hjQ=
|
||||
github.com/libtnb/sqlite v1.1.2/go.mod h1:2PGCWOR6HqqXuoIVMmwfJ655epvRO8sw0b9kHWuKNsQ=
|
||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
|
||||
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
github.com/mattn/goveralls v0.0.12 h1:PEEeF0k1SsTjOBQ8FOmrOAoCu4ytuMaWCnWe94zxbCg=
|
||||
github.com/mattn/goveralls v0.0.12/go.mod h1:44ImGEUfmqH8bBtaMrYKsM65LXfNLWmwaxFGjZwgMSQ=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
|
||||
github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
|
||||
github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
|
||||
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
|
||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
|
||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw=
|
||||
github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8=
|
||||
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||
github.com/nyaruka/phonenumbers v1.5.0 h1:0M+Gd9zl53QC4Nl5z1Yj1O/zPk2XXBUwR/vlzdXSJv4=
|
||||
github.com/nyaruka/phonenumbers v1.5.0/go.mod h1:gv+CtldaFz+G3vHHnasBSirAi3O2XLqZzVWz4V1pl2E=
|
||||
github.com/oleiade/reflections v1.1.0 h1:D+I/UsXQB4esMathlt0kkZRJZdUDmhv5zGi/HOwYTWo=
|
||||
github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/openzipkin/zipkin-go v0.4.3 h1:9EGwpqkgnwdEIJ+Od7QVSEIH+ocmm5nPat0G7sjsSdg=
|
||||
github.com/openzipkin/zipkin-go v0.4.3/go.mod h1:M9wCJZFWCo2RiY+o1eBCEMe0Dp2S5LDHcMZmk3RmK7c=
|
||||
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
|
||||
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe h1:rvu4obdvqR0fkSIJ8IfgzKOWwZ5kOT2UNfLq81Qk7rc=
|
||||
github.com/ory/go-acc v0.2.9-0.20230103102148-6b1c9a70dbbe/go.mod h1:z4n3u6as84LbV4YmgjHhnwtccQqzf4cZlSk9f1FhygI=
|
||||
github.com/ory/go-convenience v0.1.0 h1:zouLKfF2GoSGnJwGq+PE/nJAE6dj2Zj5QlTgmMTsTS8=
|
||||
github.com/ory/go-convenience v0.1.0/go.mod h1:uEY/a60PL5c12nYz4V5cHY03IBmwIAEm8TWB0yn9KNs=
|
||||
github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8 h1:bBFBzJ+sy1l/9+uYaz5TLGNNe0GWeXPMyqLhUEy9gPg=
|
||||
github.com/ory/herodot v0.10.3-0.20250318104651-3179543efba8/go.mod h1:aq2fDNzFXlh8wF6+ILtlEin2oZSrqR79/Zdsi05WEVA=
|
||||
github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e h1:4tUrC7x4YWRVMFp+c64KACNSGchW1zXo4l6Pa9/1hA8=
|
||||
github.com/ory/jsonschema/v3 v3.0.9-0.20250317235931-280c5fc7bf0e/go.mod h1:XWLxVK4un/iuIcrw+6lCeanbF3NZwO5k6RdLeu/loQk=
|
||||
github.com/ory/pop/v6 v6.4.1 h1:mxwfgwIB+kRlE4hvcoeEuxFqXZai6TWgQ23sOCBTERo=
|
||||
github.com/ory/pop/v6 v6.4.1/go.mod h1:o+a3+gdnfWUd/IpFCTKidX7sRgQ6GdPmH02XYiMH8cw=
|
||||
github.com/ory/x v0.0.729 h1:7ttCYNCjCdspI6X0oaxGAXoiYWSBrwGRz6w/IG8s3I4=
|
||||
github.com/ory/x v0.0.729/go.mod h1:qdUK3Sp4K4nRbYJG0sEnFO1tDLN/Ct53G+ymre0JhCU=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.4.1 h1:OffzqSABE3Sw354GdBThqDsKfpA4GWBqOY2P91V8tjI=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.4.1/go.mod h1:CZK8iQQMKfy6mKOifoyUmrj4vTHnMiGVaS7hDaZZxQ0=
|
||||
github.com/paulmach/orb v0.11.1 h1:3koVegMC4X/WeiXYz9iswopaTwMem53NzTJuTF20JzU=
|
||||
github.com/paulmach/orb v0.11.1/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
|
||||
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
|
||||
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6 h1:gD1LKfqFkbIHkE6d308IFJkZbHw4VwL7buxEnblNQTs=
|
||||
github.com/pocket-id/fosite v0.0.0-20260708083902-56a3c0f378d6/go.mod h1:KeQ7tTIBm3DyeBnKcKLnPbSdrd6ttM6w3TD3yy9x8rM=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk=
|
||||
github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI=
|
||||
github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/quic-go/webtransport-go v0.11.1 h1:rrFQMO+7/52ZDJ04fsrjIaWqn6q1z1MYo9iVFq6JtbA=
|
||||
github.com/quic-go/webtransport-go v0.11.1/go.mod h1:SHgEzUFVyj+9WUSuGB1P6Zd351Pww2leWV3SwlTovkA=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761 h1:0b8DF5kR0PhRoRXDiEEdzrgBc8UqVY4JWLkQJCRsLME=
|
||||
github.com/seatgeek/logrus-gelf-formatter v0.0.0-20210414080842-5b05eb8ff761/go.mod h1:/THDZYi7F/BsVEcYzYPqdcWFQ+1C2InkawTKfLOAnzg=
|
||||
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
|
||||
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
|
||||
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
|
||||
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
|
||||
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
|
||||
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/tidwall/gjson v1.19.0 h1:xwxm7n691Uf3u5OFjzngavjGTh55KX5q/9w9xHW88JU=
|
||||
github.com/tidwall/gjson v1.19.0/go.mod h1:V37/opeE/JbLUOfH0QTXiNez2l0RUjYUhpT4szFQAfc=
|
||||
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
|
||||
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
|
||||
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc=
|
||||
github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
|
||||
github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
|
||||
github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI=
|
||||
github.com/xdg-go/scram v1.1.1/go.mod h1:RaEWvsqvNKKvBPvcKeFjrG2cJqOkHTiyTpzz23ni57g=
|
||||
github.com/xdg-go/stringprep v1.0.3/go.mod h1:W3f5j4i+9rC0kuIEJL0ky1VpHXQU3ocBgklLGvcBnW8=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
|
||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
github.com/zitadel/exifremove v0.1.0 h1:qD50ezWsfeeqfcvs79QyyjVfK+snN12v0U0deaU8aKg=
|
||||
github.com/zitadel/exifremove v0.1.0/go.mod h1:rzKJ3woL/Rz2KthVBiSBKIBptNTvgmk9PLaeUKTm+ek=
|
||||
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0 h1:b9sJOYrkmt4l8bY43ZenFBcPlhYIjaOfYHLtbB/5qi8=
|
||||
go.mongodb.org/mongo-driver/v2 v2.6.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0 h1:5RgvxieNq9tS3ewrV1vnODvbHPfKUIJcYtF9Cvz+6aQ=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.19.0/go.mod h1:iTBIdNwx/xmUhfgJs6+84S4dIK059811cO1eUBjKcHY=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0 h1:saQoWg5845Q8TojpqeVStS7zGwVZ6bc5W2PJavTPiBM=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.69.0/go.mod h1:AAaS6xs5AyqMdR3Ir0nSWK+QudL2XM8Vbw5INzUxNc8=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0 h1:R3jsCoTIzv0BiYNhW0axyswn/6SMJ8xL1OuGxvni1Kw=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.69.0/go.mod h1:m07gqyr2QhQxKOKb5vqKCCBtLH3uqlNYR7PU/FISXVU=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0 h1:MCcYL7J6Vt/X0kjqbMZkekCmwsurbQRbL69vkiye2lk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/httptrace/otelhttptrace v0.69.0/go.mod h1:3jnStNwSufK+f5ktjL4EPcwtig4rtd81NS70lqHuXl8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro=
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0 h1:OyzvsAMc/zHt0DRPcfstn0wgfq8ApDkeY0ABMcueweM=
|
||||
go.opentelemetry.io/contrib/propagators/jaeger v1.44.0/go.mod h1:44kghcGX+BNxy9UTiWtd6VDt8Nd4EypGBkH2+v2Dqrc=
|
||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1 h1:pV2nZ1iE87X9ym+crkD9k15zTLbN08+IC4C7sk9sQYM=
|
||||
go.opentelemetry.io/contrib/samplers/jaegerremote v0.37.1/go.mod h1:nvgrM8LaG2+5G7WxbtjEPiSkg87+d0/ltZZK70p7FVo=
|
||||
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
|
||||
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0 h1:D7UpUy2Xc2wsi1Ras6V40q806WM07rqoCWzXu7Sqy+4=
|
||||
go.opentelemetry.io/otel/exporters/jaeger v1.17.0/go.mod h1:nPCqOnEH9rNLKqH/+rrUjiMzHJdV1BlpKcTwRTyKkKI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0 h1:rydZ9sxbcFdm/oWrVyfLTjHIygMgv0bEeMd+3B/BvoM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.20.0/go.mod h1:earQ25dooT0Hhspq59DZ8YCC50jWfOlFEeWoxy/P444=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO3X0YTDTCcDZ4V+6aVDkWbNmBoQ5NUp7Oww=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0 h1:SUplec5dp06reu1zaXmOXdvqH398taqrDXqUl99jxSc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.44.0/go.mod h1:ho2g4N+ane+swq5I/VBkKWnRDY4kUINH3FuqyZqX/Ug=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0 h1:vkrK8PAznv2NKt2r+kdu252ccGzkEqLc2aSXbQIALYQ=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.66.0/go.mod h1:V/UB6D3vMF/UBOL5igAsAYnk1nG/bzYYTzvsB16cy7o=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0 h1:aZfdmtI6QU/DAPD4b7YZ5zuJgewxO1EW9miOZklqleU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.20.0/go.mod h1:isNl10/Om5CBWu9jj8WOb2+tJLbCVXDgqwzCaJMnJ6w=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.44.0 h1:zv7PRYGLrQHkdeZj0c5SNAZOJcw55XgaTezUkNpwA+w=
|
||||
go.opentelemetry.io/otel/exporters/zipkin v1.44.0/go.mod h1:3+VZyCi6hFW+UuxFF+wSOvwsOwncfBpQfP7Qdb3JXKg=
|
||||
go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs=
|
||||
go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E=
|
||||
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
|
||||
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0 h1:YkCrx1zLOChi9ZcZ6euupOcsgzbVlec7D/xoEU1+cTA=
|
||||
go.opentelemetry.io/otel/metric/x v0.66.0/go.mod h1:d1+BDj9t96do0/1LoU1ayfCv79ZgNE41qbhBvnMOBZk=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
|
||||
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0 h1:vM3xI7TQgKPiSghe6urZtAkyFY7SodrSpC83CffDFuY=
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0/go.mod h1:Knej2nmsTUzN79T2eeXdRsjjPcoxoq2pUyUHz9TFyyU=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0 h1:OqdRZ1guyzamK3M6LlRsmGqRrjkHWw6WZOKKli5ELpg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.20.0/go.mod h1:PuMIlm7zAt7c3z8zfOI5ox4iT1Z87We+PF6YoINux/M=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
|
||||
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
|
||||
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
|
||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU=
|
||||
golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
|
||||
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200320220750-118fecf932d8/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200520182314-0ba52f642ac2/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
@@ -298,30 +709,89 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
|
||||
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
|
||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU=
|
||||
google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
gorm.io/driver/clickhouse v0.7.0 h1:BCrqvgONayvZRgtuA6hdya+eAW5P2QVagV3OlEp1vtA=
|
||||
gorm.io/driver/clickhouse v0.7.0/go.mod h1:TmNo0wcVTsD4BBObiRnCahUgHJHjBIwuRejHwYt3JRs=
|
||||
gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo=
|
||||
gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gorm.io/plugin/opentelemetry v0.1.16 h1:Kypj2YYAliJqkIczDZDde6P6sFMhKSlG5IpngMFQGpc=
|
||||
gorm.io/plugin/opentelemetry v0.1.16/go.mod h1:P3RmTeZXT+9n0F1ccUqR5uuTvEXDxF8k2UpO7mTIB2Y=
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI=
|
||||
k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
|
||||
modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc=
|
||||
modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
|
||||
modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q=
|
||||
modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI=
|
||||
modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw=
|
||||
modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
|
||||
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
|
||||
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
57
backend/internal/api/dto.go
Normal file
57
backend/internal/api/dto.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// apiResponseDto is the full representation of an API including its permissions
|
||||
type apiResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Resource string `json:"resource"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
Permissions []apiPermissionResponseDto `json:"permissions"`
|
||||
}
|
||||
|
||||
type apiPermissionResponseDto struct {
|
||||
ID string `json:"id"`
|
||||
Key string `json:"key"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
}
|
||||
|
||||
// apiCreateDto is the payload for creating an API
|
||||
// The resource identifier is only accepted here because changing it later would invalidate every token already minted for the API
|
||||
type apiCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Resource string `json:"resource" binding:"required,resource_uri,max=350" unorm:"nfc"`
|
||||
}
|
||||
|
||||
// apiUpdateDto is the payload for updating an API
|
||||
// The resource identifier is intentionally not updatable
|
||||
type apiUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
}
|
||||
|
||||
type apiPermissionInputDto struct {
|
||||
Key string `json:"key" binding:"required,min=1,max=128" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" binding:"omitempty,max=200"`
|
||||
}
|
||||
|
||||
// apiPermissionsUpdateDto replaces the full permission set of an API
|
||||
type apiPermissionsUpdateDto struct {
|
||||
Permissions []apiPermissionInputDto `json:"permissions" binding:"omitempty,dive"`
|
||||
}
|
||||
|
||||
// clientApiAccessDto is the set of API permissions a client is allowed to request, split by subject type
|
||||
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
|
||||
type clientApiAccessDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds"`
|
||||
}
|
||||
|
||||
type clientApiAccessUpdateDto struct {
|
||||
UserDelegatedPermissionIDs []string `json:"userDelegatedPermissionIds" binding:"omitempty,dive,required"`
|
||||
ClientPermissionIDs []string `json:"clientPermissionIds" binding:"omitempty,dive,required"`
|
||||
}
|
||||
235
backend/internal/api/handler.go
Normal file
235
backend/internal/api/handler.go
Normal file
@@ -0,0 +1,235 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List APIs
|
||||
// @Description Get a paginated list of APIs with optional search and sorting
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param search query string false "Search term to filter APIs by name or resource"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiResponseDto]
|
||||
// @Router /api/apis [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
search := c.Query("search")
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
apis, pagination, err := h.service.List(c.Request.Context(), search, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
items := make([]apiResponseDto, len(apis))
|
||||
for i, api := range apis {
|
||||
var item apiResponseDto
|
||||
if err := dto.MapStruct(api, &item); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
item.Resource = api.Audience
|
||||
items[i] = item
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiResponseDto]{
|
||||
Data: items,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// get godoc
|
||||
// @Summary Get API by ID
|
||||
// @Description Retrieve a single API including its permissions
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 200 {object} apiResponseDto
|
||||
// @Router /api/apis/{id} [get]
|
||||
func (h *handler) get(c *gin.Context) {
|
||||
api, err := h.service.Get(c.Request.Context(), nil, c.Param("id"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API
|
||||
// @Description Create a new API resource server
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param api body apiCreateDto true "API information"
|
||||
// @Success 201 {object} apiResponseDto "Created API"
|
||||
// @Router /api/apis [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
var input apiCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Create(c.Request.Context(), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusCreated, api)
|
||||
}
|
||||
|
||||
// update godoc
|
||||
// @Summary Update API
|
||||
// @Description Update an existing API by ID
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param api body apiUpdateDto true "API information"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id} [put]
|
||||
func (h *handler) update(c *gin.Context) {
|
||||
var input apiUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.Update(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// delete godoc
|
||||
// @Summary Delete API
|
||||
// @Description Delete an API by ID
|
||||
// @Tags APIs
|
||||
// @Param id path string true "API ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/apis/{id} [delete]
|
||||
func (h *handler) delete(c *gin.Context) {
|
||||
if err := h.service.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updatePermissions godoc
|
||||
// @Summary Update API permissions
|
||||
// @Description Replace the full set of permissions for an API
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "API ID"
|
||||
// @Param permissions body apiPermissionsUpdateDto true "Permissions to set"
|
||||
// @Success 200 {object} apiResponseDto "Updated API"
|
||||
// @Router /api/apis/{id}/permissions [put]
|
||||
func (h *handler) updatePermissions(c *gin.Context) {
|
||||
var input apiPermissionsUpdateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
api, err := h.service.UpdatePermissions(c.Request.Context(), c.Param("id"), input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
h.respond(c, http.StatusOK, api)
|
||||
}
|
||||
|
||||
// getClientAccess godoc
|
||||
// @Summary Get client API access
|
||||
// @Description Get the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [get]
|
||||
func (h *handler) getClientAccess(c *gin.Context) {
|
||||
access, err := h.service.GetClientAPIAccess(c.Request.Context(), c.Param("clientId"))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(access))
|
||||
}
|
||||
|
||||
// updateClientAccess godoc
|
||||
// @Summary Update client API access
|
||||
// @Description Replace the API permissions an OIDC client is allowed to request, split into user-delegated and client (machine-to-machine) access
|
||||
// @Tags APIs
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param clientId path string true "OIDC Client ID"
|
||||
// @Param access body clientApiAccessUpdateDto true "Allowed permission IDs per subject type"
|
||||
// @Success 200 {object} clientApiAccessDto
|
||||
// @Router /api/api-access/{clientId} [put]
|
||||
func (h *handler) updateClientAccess(c *gin.Context) {
|
||||
var input clientApiAccessUpdateDto
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
applied, err := h.service.SetClientAPIAccess(c.Request.Context(), c.Param("clientId"), ClientAPIAccess(input))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, newClientApiAccessDto(applied))
|
||||
}
|
||||
|
||||
// newClientApiAccessDto always serializes both permission lists as arrays rather than null
|
||||
func newClientApiAccessDto(access ClientAPIAccess) clientApiAccessDto {
|
||||
dto := clientApiAccessDto(access)
|
||||
if dto.UserDelegatedPermissionIDs == nil {
|
||||
dto.UserDelegatedPermissionIDs = []string{}
|
||||
}
|
||||
if dto.ClientPermissionIDs == nil {
|
||||
dto.ClientPermissionIDs = []string{}
|
||||
}
|
||||
return dto
|
||||
}
|
||||
|
||||
func (h *handler) respond(c *gin.Context, status int, api API) {
|
||||
var responseDto apiResponseDto
|
||||
if err := dto.MapStruct(api, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
responseDto.Resource = api.Audience
|
||||
c.JSON(status, responseDto)
|
||||
}
|
||||
38
backend/internal/api/models.go
Normal file
38
backend/internal/api/models.go
Normal file
@@ -0,0 +1,38 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
type API struct {
|
||||
model.Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Audience string `sortable:"true"`
|
||||
UpdatedAt *datatype.DateTime
|
||||
|
||||
Permissions []Permission `gorm:"foreignKey:APIID;references:ID;constraint:OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
type Permission struct {
|
||||
model.Base
|
||||
|
||||
APIID string `gorm:"column:api_id"`
|
||||
Key string `sortable:"true"`
|
||||
Name string
|
||||
Description *string
|
||||
}
|
||||
|
||||
func (Permission) TableName() string { return "api_permissions" }
|
||||
|
||||
type OidcClientAllowedAPIPermission struct {
|
||||
OidcClientID string
|
||||
APIPermissionID string
|
||||
SubjectType oidc.SubjectType
|
||||
}
|
||||
|
||||
func (OidcClientAllowedAPIPermission) TableName() string {
|
||||
return "oidc_clients_allowed_api_permissions"
|
||||
}
|
||||
78
backend/internal/api/module.go
Normal file
78
backend/internal/api/module.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
// Issuer is the OpenID Provider issuer URL, reserved so a custom API cannot claim it as its audience
|
||||
Issuer string
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(deps Dependencies) *Module {
|
||||
service := newService(deps.DB, deps.Issuer)
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientAPIScopes implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) ClientAPIScopes(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
return m.service.ClientAPIScopesAndAudiences(ctx, tx, clientID)
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
return m.service.AllowedScopesForAudience(ctx, tx, clientID, audience, subjectType)
|
||||
}
|
||||
|
||||
// DescribePermissions implements the OIDC module's APIAccessProvider interface
|
||||
func (m *Module) DescribePermissions(ctx context.Context, audience string, keys []string) ([]dto.ScopeInfoDto, error) {
|
||||
permissions, err := m.service.DescribePermissions(ctx, audience, keys)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
infos := make([]dto.ScopeInfoDto, len(permissions))
|
||||
for i, permission := range permissions {
|
||||
description := ""
|
||||
if permission.Description != nil {
|
||||
description = *permission.Description
|
||||
}
|
||||
infos[i] = dto.ScopeInfoDto{Key: permission.Key, Name: permission.Name, Description: description}
|
||||
}
|
||||
|
||||
return infos, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the admin CRUD endpoints
|
||||
// adminAuth is passed in as a gin handler so the module does not import internal/middleware
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, adminAuth gin.HandlerFunc) {
|
||||
apis := apiGroup.Group("/apis")
|
||||
apis.Use(adminAuth)
|
||||
apis.GET("", m.handler.list)
|
||||
apis.POST("", m.handler.create)
|
||||
apis.GET("/:id", m.handler.get)
|
||||
apis.PUT("/:id", m.handler.update)
|
||||
apis.DELETE("/:id", m.handler.delete)
|
||||
apis.PUT("/:id/permissions", m.handler.updatePermissions)
|
||||
|
||||
// The per-client API-access allow-list lives on a separate path so it does not collide with the /apis/:id wildcard
|
||||
access := apiGroup.Group("/api-access")
|
||||
access.Use(adminAuth)
|
||||
access.GET("/:clientId", m.handler.getClientAccess)
|
||||
access.PUT("/:clientId", m.handler.updateClientAccess)
|
||||
}
|
||||
484
backend/internal/api/service.go
Normal file
484
backend/internal/api/service.go
Normal file
@@ -0,0 +1,484 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/ory/fosite"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// isPermissionKeyReserved reports whether the key is a scope or claim name owned by Pocket ID's built-in identity layer
|
||||
// A custom API permission must not reuse one, otherwise its scope string would collide with a standard OIDC scope or claim
|
||||
func isPermissionKeyReserved(key string) bool {
|
||||
switch strings.ToLower(key) {
|
||||
case "openid", "profile", "email", "email_verified", "groups", "offline_access":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// isValidPermissionKey reports whether the key consists only of RFC 6749 scope-token characters, which are printable ASCII without space, double-quote or backslash
|
||||
// This keeps a key safe as a space-delimited value in the token scope claim and free of the control character used to qualify consent records
|
||||
func isValidPermissionKey(key string) bool {
|
||||
return fosite.IsValidScopeToken(key)
|
||||
}
|
||||
|
||||
// Service holds the business logic for managing APIs and their permissions
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
issuer string
|
||||
}
|
||||
|
||||
func newService(db *gorm.DB, issuer string) *Service {
|
||||
return &Service{db: db, issuer: issuer}
|
||||
}
|
||||
|
||||
// isIssuerAudience reports whether the audience refers to Pocket ID itself (the issuer)
|
||||
// A custom API must not claim the issuer as its audience, otherwise its tokens would be indistinguishable from Pocket ID's own identity tokens
|
||||
func isIssuerAudience(audience, issuer string) bool {
|
||||
return issuer != "" && strings.ToLower(strings.TrimRight(audience, "/")) == issuer
|
||||
}
|
||||
|
||||
func (s *Service) List(ctx context.Context, search string, listRequestOptions utils.ListRequestOptions) (apis []API, response utils.PaginationResponse, err error) {
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Preload("Permissions").
|
||||
Model(&API{})
|
||||
|
||||
if listRequestOptions.Sort.Column == "resource" {
|
||||
listRequestOptions.Sort.Column = "audience"
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
like := "%" + search + "%"
|
||||
query = query.Where("name LIKE ? OR audience LIKE ?", like, like)
|
||||
}
|
||||
|
||||
response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &apis)
|
||||
return apis, response, err
|
||||
}
|
||||
|
||||
// Get loads an API and its permissions
|
||||
func (s *Service) Get(ctx context.Context, tx *gorm.DB, id string) (api API, err error) {
|
||||
query := s.db.WithContext(ctx)
|
||||
if tx != nil {
|
||||
query = tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"})
|
||||
}
|
||||
|
||||
err = query.
|
||||
Preload("Permissions").
|
||||
Where("id = ?", id).
|
||||
First(&api).
|
||||
Error
|
||||
return api, err
|
||||
}
|
||||
|
||||
func (s *Service) Create(ctx context.Context, input apiCreateDto) (api API, err error) {
|
||||
// Reject the issuer as an audience so a custom API cannot impersonate Pocket ID's own identity tokens
|
||||
if isIssuerAudience(input.Resource, s.issuer) {
|
||||
return API{}, &common.ValidationError{Message: "the resource is reserved by Pocket ID and cannot be used for a custom API"}
|
||||
}
|
||||
|
||||
api = API{
|
||||
Name: input.Name,
|
||||
Audience: input.Resource,
|
||||
}
|
||||
|
||||
err = s.db.WithContext(ctx).Create(&api).Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return API{}, &common.AlreadyInUseError{Property: "resource"}
|
||||
}
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Service) Update(ctx context.Context, id string, input apiUpdateDto) (api API, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
api.Name = input.Name
|
||||
api.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
|
||||
err = tx.WithContext(ctx).Save(&api).Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
func (s *Service) Delete(ctx context.Context, id string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err := s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = s.deletePermissions(ctx, tx, collectIDs(api.Permissions)); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = tx.WithContext(ctx).Delete(&API{}, "id = ?", id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
}
|
||||
|
||||
// UpdatePermissions replaces the full permission set of an API, matching existing permissions by key
|
||||
// Unchanged keys keep their grants, removed keys and their client grants are deleted, and new keys are inserted
|
||||
func (s *Service) UpdatePermissions(ctx context.Context, id string, input apiPermissionsUpdateDto) (api API, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
// Reject keys with invalid characters, that collide with Pocket ID's reserved scopes and claims, or that repeat within the request before persisting anything
|
||||
// A duplicate key would otherwise be silently coalesced last-wins into the map below, dropping a row behind a 200
|
||||
seen := make(map[string]struct{}, len(input.Permissions))
|
||||
for _, permission := range input.Permissions {
|
||||
if !isValidPermissionKey(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q contains invalid characters", permission.Key)}
|
||||
}
|
||||
if isPermissionKeyReserved(permission.Key) {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is reserved by Pocket ID", permission.Key)}
|
||||
}
|
||||
_, ok := seen[permission.Key]
|
||||
if ok {
|
||||
return API{}, &common.ValidationError{Message: fmt.Sprintf("the permission key %q is listed more than once", permission.Key)}
|
||||
}
|
||||
seen[permission.Key] = struct{}{}
|
||||
}
|
||||
|
||||
existing := make(map[string]Permission, len(api.Permissions))
|
||||
for _, p := range api.Permissions {
|
||||
existing[p.Key] = p
|
||||
}
|
||||
|
||||
wanted := make(map[string]apiPermissionInputDto, len(input.Permissions))
|
||||
var removedIDs []string
|
||||
for _, in := range input.Permissions {
|
||||
wanted[in.Key] = in
|
||||
}
|
||||
|
||||
// Delete permissions whose key is no longer wanted
|
||||
for key, p := range existing {
|
||||
if _, ok := wanted[key]; !ok {
|
||||
removedIDs = append(removedIDs, p.ID)
|
||||
}
|
||||
}
|
||||
if err = s.deletePermissions(ctx, tx, removedIDs); err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
// Insert new keys and update the display fields of existing ones
|
||||
for key, in := range wanted {
|
||||
if cur, ok := existing[key]; ok {
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("id = ?", cur.ID).
|
||||
Updates(map[string]any{"name": in.Name, "description": in.Description}).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
newPermission := Permission{
|
||||
APIID: api.ID,
|
||||
Key: in.Key,
|
||||
Name: in.Name,
|
||||
Description: in.Description,
|
||||
}
|
||||
if err = tx.WithContext(ctx).Create(&newPermission).Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Model(&API{}).
|
||||
Where("id = ?", api.ID).
|
||||
Update("updated_at", new(datatype.DateTime(time.Now()))).
|
||||
Error
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
api, err = s.Get(ctx, tx, id)
|
||||
if err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return API{}, err
|
||||
}
|
||||
|
||||
return api, nil
|
||||
}
|
||||
|
||||
// ClientAPIAccess is the set of API permissions granted to a client, split by the subject the resulting tokens act for
|
||||
// User-delegated permissions may be requested on behalf of a signed-in user, client permissions may be obtained by the client itself through the client credentials grant
|
||||
type ClientAPIAccess struct {
|
||||
UserDelegatedPermissionIDs []string
|
||||
ClientPermissionIDs []string
|
||||
}
|
||||
|
||||
// GetClientAPIAccess returns the API permissions a client is allowed to request, split by subject type
|
||||
// Only custom-API permissions are tracked here because the identity scopes are freely requestable by every client
|
||||
func (s *Service) GetClientAPIAccess(ctx context.Context, clientID string) (access ClientAPIAccess, err error) {
|
||||
var rows []OidcClientAllowedAPIPermission
|
||||
err = s.db.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Find(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
switch row.SubjectType {
|
||||
case oidc.SubjectTypeClient:
|
||||
access.ClientPermissionIDs = append(access.ClientPermissionIDs, row.APIPermissionID)
|
||||
case oidc.SubjectTypeUser:
|
||||
access.UserDelegatedPermissionIDs = append(access.UserDelegatedPermissionIDs, row.APIPermissionID)
|
||||
default:
|
||||
// Nop - ignore
|
||||
}
|
||||
}
|
||||
|
||||
return access, nil
|
||||
}
|
||||
|
||||
// SetClientAPIAccess replaces the client's API-access grants for both subject types with the given permission IDs
|
||||
// Unknown permission IDs are ignored
|
||||
// It returns the access that was actually applied
|
||||
func (s *Service) SetClientAPIAccess(ctx context.Context, clientID string, access ClientAPIAccess) (applied ClientAPIAccess, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Ensure the client exists so callers get a 404 for an unknown client
|
||||
var client model.OidcClient
|
||||
if err = tx.WithContext(ctx).Select("id").Where("id = ?", clientID).First(&client).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
applied.UserDelegatedPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.UserDelegatedPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
applied.ClientPermissionIDs, err = s.filterAssignablePermissionIDs(ctx, tx, access.ClientPermissionIDs)
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
// Replace the grants for this client
|
||||
err = tx.WithContext(ctx).
|
||||
Where("oidc_client_id = ?", clientID).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
rows := make([]OidcClientAllowedAPIPermission, 0, len(applied.UserDelegatedPermissionIDs)+len(applied.ClientPermissionIDs))
|
||||
for _, permissionID := range applied.UserDelegatedPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeUser})
|
||||
}
|
||||
for _, permissionID := range applied.ClientPermissionIDs {
|
||||
rows = append(rows, OidcClientAllowedAPIPermission{OidcClientID: clientID, APIPermissionID: permissionID, SubjectType: oidc.SubjectTypeClient})
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
if err = tx.WithContext(ctx).Create(&rows).Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err = tx.Commit().Error; err != nil {
|
||||
return ClientAPIAccess{}, err
|
||||
}
|
||||
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// ClientAPIScopesAndAudiences returns the permission keys a client may request and the distinct audiences of the custom APIs those permissions belong to, across both subject types
|
||||
// The OIDC module uses this to widen fosite's scope and audience validation for the client; the per-flow subject-type enforcement happens when the resource is resolved
|
||||
func (s *Service) ClientAPIScopesAndAudiences(ctx context.Context, tx *gorm.DB, clientID string) (scopes []string, audiences []string, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
var rows []struct {
|
||||
Key string
|
||||
Audience string
|
||||
}
|
||||
err = tx.WithContext(ctx).
|
||||
Table("oidc_clients_allowed_api_permissions AS g").
|
||||
Select("api_permissions.key AS key, apis.audience AS audience").
|
||||
Joins("JOIN api_permissions ON api_permissions.id = g.api_permission_id").
|
||||
Joins("JOIN apis ON apis.id = api_permissions.api_id").
|
||||
Where("g.oidc_client_id = ?", clientID).
|
||||
Scan(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
scopeSeen := make(map[string]struct{}, len(rows))
|
||||
audienceSeen := make(map[string]struct{}, len(rows))
|
||||
scopes = make([]string, 0, len(rows))
|
||||
audiences = make([]string, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
if _, ok := scopeSeen[row.Key]; !ok {
|
||||
scopeSeen[row.Key] = struct{}{}
|
||||
scopes = append(scopes, row.Key)
|
||||
}
|
||||
if _, ok := audienceSeen[row.Audience]; !ok {
|
||||
audienceSeen[row.Audience] = struct{}{}
|
||||
audiences = append(audiences, row.Audience)
|
||||
}
|
||||
}
|
||||
|
||||
return scopes, audiences, nil
|
||||
}
|
||||
|
||||
// AllowedScopesForAudience returns the permission keys the client is allowed for the API identified by the given audience and subject type, plus whether such an API exists
|
||||
func (s *Service) AllowedScopesForAudience(ctx context.Context, tx *gorm.DB, clientID, audience string, subjectType oidc.SubjectType) (scopes []string, apiExists bool, err error) {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
|
||||
var api API
|
||||
err = tx.WithContext(ctx).
|
||||
Select("id").
|
||||
Where("audience = ?", audience).
|
||||
First(&api).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Table("api_permissions").
|
||||
Select("api_permissions.key").
|
||||
Joins("JOIN oidc_clients_allowed_api_permissions g ON g.api_permission_id = api_permissions.id AND g.oidc_client_id = ? AND g.subject_type = ?", clientID, subjectType).
|
||||
Where("api_permissions.api_id = ?", api.ID).
|
||||
Pluck("api_permissions.key", &scopes).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
return scopes, true, nil
|
||||
}
|
||||
|
||||
// DescribePermissions returns the permission rows of the API identified by the given audience whose key is in keys
|
||||
// The consent screen uses these to show friendly names instead of raw scope keys
|
||||
func (s *Service) DescribePermissions(ctx context.Context, audience string, keys []string) ([]Permission, error) {
|
||||
if len(keys) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var permissions []Permission
|
||||
err := s.db.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Joins("JOIN apis ON apis.id = api_permissions.api_id").
|
||||
Where("apis.audience = ? AND api_permissions.key IN ?", audience, keys).
|
||||
Find(&permissions).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return permissions, nil
|
||||
}
|
||||
|
||||
// filterAssignablePermissionIDs returns the subset of the given permission IDs that exist
|
||||
func (s *Service) filterAssignablePermissionIDs(ctx context.Context, tx *gorm.DB, permissionIDs []string) ([]string, error) {
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
var valid []string
|
||||
err := tx.WithContext(ctx).
|
||||
Model(&Permission{}).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Pluck("id", &valid).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return valid, nil
|
||||
}
|
||||
|
||||
// deletePermissions removes permissions by ID along with any client allow-list grants that reference them
|
||||
// The explicit grant delete keeps this correct even when the database does not enforce ON DELETE CASCADE at runtime
|
||||
func (s *Service) deletePermissions(ctx context.Context, tx *gorm.DB, permissionIDs []string) error {
|
||||
if tx == nil {
|
||||
tx = s.db
|
||||
}
|
||||
if len(permissionIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
err := tx.WithContext(ctx).
|
||||
Where("api_permission_id IN ?", permissionIDs).
|
||||
Delete(&OidcClientAllowedAPIPermission{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.WithContext(ctx).
|
||||
Where("id IN ?", permissionIDs).
|
||||
Delete(&Permission{}).
|
||||
Error
|
||||
}
|
||||
|
||||
func collectIDs(permissions []Permission) []string {
|
||||
ids := make([]string, len(permissions))
|
||||
for i, p := range permissions {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
return ids
|
||||
}
|
||||
283
backend/internal/api/service_test.go
Normal file
283
backend/internal/api/service_test.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestAPICrudAndPermissionDiff(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
created, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders API", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, created.ID)
|
||||
|
||||
// The resource is unique.
|
||||
_, err = svc.Create(t.Context(), apiCreateDto{Name: "Dup", Resource: "https://api.orders.example.com"})
|
||||
require.ErrorIs(t, err, &common.AlreadyInUseError{})
|
||||
|
||||
desc := "Read orders"
|
||||
updated, err := svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
||||
{Key: "write:orders", Name: "Write orders"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, updated.Permissions, 2)
|
||||
|
||||
// Grant a client the read:orders permission for both subject types, then remove that permission
|
||||
// and confirm the grants are cleaned up while write:orders (and its key) survives.
|
||||
readPerm := findPermission(updated, "read:orders")
|
||||
require.NotNil(t, readPerm)
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeUser}).Error)
|
||||
require.NoError(t, db.Create(&OidcClientAllowedAPIPermission{OidcClientID: "client-1", APIPermissionID: readPerm.ID, SubjectType: oidc.SubjectTypeClient}).Error)
|
||||
|
||||
updated, err = svc.UpdatePermissions(t.Context(), created.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "write:orders", Name: "Write orders (renamed)"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, updated.Permissions, 1)
|
||||
assert.Equal(t, "write:orders", updated.Permissions[0].Key)
|
||||
assert.Equal(t, "Write orders (renamed)", updated.Permissions[0].Name)
|
||||
|
||||
var grantCount int64
|
||||
require.NoError(t, db.Model(&OidcClientAllowedAPIPermission{}).Where("api_permission_id = ?", readPerm.ID).Count(&grantCount).Error)
|
||||
assert.Equal(t, int64(0), grantCount)
|
||||
|
||||
renamed, err := svc.Update(t.Context(), created.ID, apiUpdateDto{Name: "Orders"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "Orders", renamed.Name)
|
||||
require.NotNil(t, renamed.UpdatedAt)
|
||||
|
||||
require.NoError(t, svc.Delete(t.Context(), created.ID))
|
||||
_, err = svc.Get(t.Context(), nil, created.ID)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestClientApiAccessAllowList(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
// Unknown IDs are filtered out, and the subject types are stored independently.
|
||||
applied, err := svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID, "does-not-exist"},
|
||||
ClientPermissionIDs: []string{writeID, "does-not-exist"},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, applied.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, applied.ClientPermissionIDs)
|
||||
|
||||
got, err := svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{writeID}, got.ClientPermissionIDs)
|
||||
|
||||
// The same permission can be granted for both subject types, and both sets are fully replaced on each call.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID, writeID},
|
||||
ClientPermissionIDs: []string{readID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{readID, writeID}, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing one subject type leaves the other untouched.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{ClientPermissionIDs: []string{readID}})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.ElementsMatch(t, []string{readID}, got.ClientPermissionIDs)
|
||||
|
||||
// Clearing everything.
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{})
|
||||
require.NoError(t, err)
|
||||
got, err = svc.GetClientAPIAccess(t.Context(), "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, got.UserDelegatedPermissionIDs)
|
||||
assert.Empty(t, got.ClientPermissionIDs)
|
||||
|
||||
// An unknown client is rejected (surfaces as 404 at the HTTP layer).
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "nope", ClientAPIAccess{UserDelegatedPermissionIDs: []string{readID}})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// TestAllowedScopesForAudienceFiltersBySubjectType guards that the scopes resolved for a flow
|
||||
// only come from the grants of that flow's subject type.
|
||||
func TestAllowedScopesForAudienceFiltersBySubjectType(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
require.NoError(t, db.Create(&model.OidcClient{Base: model.Base{ID: "client-1"}, Name: "Client 1"}).Error)
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
orders, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "write:orders", Name: "Write"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
readID := findPermission(orders, "read:orders").ID
|
||||
writeID := findPermission(orders, "write:orders").ID
|
||||
|
||||
_, err = svc.SetClientAPIAccess(t.Context(), "client-1", ClientAPIAccess{
|
||||
UserDelegatedPermissionIDs: []string{readID},
|
||||
ClientPermissionIDs: []string{writeID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
userScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeUser)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.ElementsMatch(t, []string{"read:orders"}, userScopes)
|
||||
|
||||
clientScopes, exists, err := svc.AllowedScopesForAudience(t.Context(), nil, "client-1", "https://api.orders.example.com", oidc.SubjectTypeClient)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists)
|
||||
assert.ElementsMatch(t, []string{"write:orders"}, clientScopes)
|
||||
|
||||
// The fosite widening still sees the union of both subject types.
|
||||
scopes, audiences, err := svc.ClientAPIScopesAndAudiences(t.Context(), nil, "client-1")
|
||||
require.NoError(t, err)
|
||||
assert.ElementsMatch(t, []string{"read:orders", "write:orders"}, scopes)
|
||||
assert.ElementsMatch(t, []string{"https://api.orders.example.com"}, audiences)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsReservedKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, key := range []string{"openid", "profile", "email", "email_verified", "groups", "offline_access", "Email"} {
|
||||
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: key, Name: "Reserved"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsDuplicateKeys(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Two rows with the same key must be rejected rather than silently coalesced last-wins
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
{Key: "read:orders", Name: "Read again"},
|
||||
}})
|
||||
require.Error(t, err)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
func TestUpdatePermissionsRejectsInvalidKeyCharacters(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
|
||||
// A space corrupts the space-delimited scope claim, and the unit separator is the consent delimiter
|
||||
for _, key := range []string{"read orders", "read\x1forders", "read\"orders", "bad\\key", "tab\tkey"} {
|
||||
_, err := svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: key, Name: "Invalid"},
|
||||
}})
|
||||
require.Error(t, err, "key %q must be rejected", key)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
// A valid scope-token key is accepted
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateRejectsIssuerResource(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
const issuer = "https://id.example.com"
|
||||
svc := New(Dependencies{DB: db, Issuer: issuer}).service
|
||||
|
||||
// The issuer itself, a trailing-slash variant, and a different-cased variant are all reserved
|
||||
for _, resource := range []string{issuer, issuer + "/", "https://ID.example.com"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Reserved", Resource: resource})
|
||||
require.Error(t, err, "resource %q must be rejected", resource)
|
||||
var validationErr *common.ValidationError
|
||||
require.ErrorAs(t, err, &validationErr)
|
||||
}
|
||||
|
||||
// A normal resource is accepted
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestCreateAcceptsAbsoluteResourceURIs(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
for _, resource := range []string{"https://api.orders.example.com", "api://PocketID", "urn:my-app"} {
|
||||
_, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: resource})
|
||||
require.NoError(t, err, "resource %q must be accepted", resource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribePermissions(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
svc := New(Dependencies{DB: db}).service
|
||||
|
||||
orders, err := svc.Create(t.Context(), apiCreateDto{Name: "Orders", Resource: "https://api.orders.example.com"})
|
||||
require.NoError(t, err)
|
||||
desc := "Read orders"
|
||||
_, err = svc.UpdatePermissions(t.Context(), orders.ID, apiPermissionsUpdateDto{Permissions: []apiPermissionInputDto{
|
||||
{Key: "read:orders", Name: "Read orders", Description: &desc},
|
||||
{Key: "write:orders", Name: "Write orders"},
|
||||
}})
|
||||
require.NoError(t, err)
|
||||
|
||||
infos, err := svc.DescribePermissions(t.Context(), "https://api.orders.example.com", []string{"read:orders", "unknown"})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, infos, 1)
|
||||
assert.Equal(t, "read:orders", infos[0].Key)
|
||||
assert.Equal(t, "Read orders", infos[0].Name)
|
||||
require.NotNil(t, infos[0].Description)
|
||||
assert.Equal(t, "Read orders", *infos[0].Description)
|
||||
}
|
||||
|
||||
func findPermission(api API, key string) *Permission {
|
||||
for i := range api.Permissions {
|
||||
if api.Permissions[i].Key == key {
|
||||
return &api.Permissions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
30
backend/internal/apikey/dto.go
Normal file
30
backend/internal/apikey/dto.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type apiKeyCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" unorm:"nfc"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type apiKeyRenewDto struct {
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type apiKeyDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
ExpirationEmailSent bool `json:"expirationEmailSent"`
|
||||
}
|
||||
|
||||
type apiKeyResponseDto struct {
|
||||
ApiKey apiKeyDto `json:"apiKey"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
139
backend/internal/apikey/handler.go
Normal file
139
backend/internal/apikey/handler.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
service *Service
|
||||
}
|
||||
|
||||
func newHandler(service *Service) *handler {
|
||||
return &handler{service: service}
|
||||
}
|
||||
|
||||
// list godoc
|
||||
// @Summary List API keys
|
||||
// @Description Get a paginated list of API keys belonging to the current user
|
||||
// @Tags API Keys
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[apiKeyDto]
|
||||
// @Router /api/api-keys [get]
|
||||
func (h *handler) list(c *gin.Context) {
|
||||
listRequestOptions := utils.ParseListRequestOptions(c)
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
apiKeys, pagination, err := h.service.ListApiKeys(c.Request.Context(), userID, listRequestOptions)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var apiKeysDto []apiKeyDto
|
||||
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[apiKeyDto]{
|
||||
Data: apiKeysDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// create godoc
|
||||
// @Summary Create API key
|
||||
// @Description Create a new API key for the current user
|
||||
// @Tags API Keys
|
||||
// @Param api_key body apiKeyCreateDto true "API key information"
|
||||
// @Success 201 {object} apiKeyResponseDto "Created API key with token"
|
||||
// @Router /api/api-keys [post]
|
||||
func (h *handler) create(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
var input apiKeyCreateDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, token, err := h.service.CreateApiKey(c.Request.Context(), userID, input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var responseDto apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusCreated, apiKeyResponseDto{
|
||||
ApiKey: responseDto,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
// renew godoc
|
||||
// @Summary Renew API key
|
||||
// @Description Renew an existing API key by ID
|
||||
// @Tags API Keys
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 200 {object} apiKeyResponseDto "Renewed API key with new token"
|
||||
// @Router /api/api-keys/{id}/renew [post]
|
||||
func (h *handler) renew(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
apiKeyID := c.Param("id")
|
||||
|
||||
var input apiKeyRenewDto
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, token, err := h.service.RenewApiKey(c.Request.Context(), userID, apiKeyID, input.ExpiresAt.ToTime())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var responseDto apiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &responseDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, apiKeyResponseDto{
|
||||
ApiKey: responseDto,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
// revoke godoc
|
||||
// @Summary Revoke API key
|
||||
// @Description Revoke (delete) an existing API key by ID
|
||||
// @Tags API Keys
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/api-keys/{id} [delete]
|
||||
func (h *handler) revoke(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
apiKeyID := c.Param("id")
|
||||
|
||||
if err := h.service.RevokeApiKey(c.Request.Context(), userID, apiKeyID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
21
backend/internal/apikey/models.go
Normal file
21
backend/internal/apikey/models.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
// ApiKey is a personal access token a user can use to authenticate against the API
|
||||
type ApiKey struct {
|
||||
model.Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Key string
|
||||
Description *string
|
||||
ExpiresAt datatype.DateTime `sortable:"true"`
|
||||
LastUsedAt *datatype.DateTime `sortable:"true"`
|
||||
ExpirationEmailSent bool
|
||||
|
||||
UserID string
|
||||
User model.User
|
||||
}
|
||||
58
backend/internal/apikey/module.go
Normal file
58
backend/internal/apikey/module.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
type Dependencies struct {
|
||||
DB *gorm.DB
|
||||
StaticApiKey string
|
||||
}
|
||||
|
||||
type Module struct {
|
||||
service *Service
|
||||
handler *handler
|
||||
}
|
||||
|
||||
func New(ctx context.Context, deps Dependencies) (*Module, error) {
|
||||
service, err := newService(ctx, deps.DB, deps.StaticApiKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Module{
|
||||
service: service,
|
||||
handler: newHandler(service),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RegisterRoutes mounts the API key management endpoints
|
||||
// authWithoutApiKey disables API key authentication so an API key cannot be used to mint or renew further API keys
|
||||
func (m *Module) RegisterRoutes(apiGroup *gin.RouterGroup, auth, authWithoutApiKey gin.HandlerFunc) {
|
||||
group := apiGroup.Group("/api-keys")
|
||||
group.GET("", auth, m.handler.list)
|
||||
group.POST("", authWithoutApiKey, m.handler.create)
|
||||
group.POST("/:id/renew", authWithoutApiKey, m.handler.renew)
|
||||
group.DELETE("/:id", auth, m.handler.revoke)
|
||||
}
|
||||
|
||||
// ValidateApiKey resolves the user that owns the given raw API key
|
||||
// It is used by the authentication middleware
|
||||
func (m *Module) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
|
||||
return m.service.ValidateApiKey(ctx, apiKey)
|
||||
}
|
||||
|
||||
// ListExpiringApiKeys returns API keys expiring within the given number of days that have not been notified yet
|
||||
func (m *Module) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
|
||||
return m.service.ListExpiringApiKeys(ctx, daysAhead)
|
||||
}
|
||||
|
||||
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
|
||||
func (m *Module) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
|
||||
return m.service.MarkExpirationEmailSent(ctx, apiKeyID)
|
||||
}
|
||||
257
backend/internal/apikey/service.go
Normal file
257
backend/internal/apikey/service.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
// Service holds the business logic for managing user API keys
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
staticApiKey string
|
||||
}
|
||||
|
||||
func newService(ctx context.Context, db *gorm.DB, staticApiKey string) (*Service, error) {
|
||||
s := &Service{
|
||||
db: db,
|
||||
staticApiKey: staticApiKey,
|
||||
}
|
||||
|
||||
if staticApiKey == "" {
|
||||
err := s.deleteStaticApiKeyUser(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]ApiKey, utils.PaginationResponse, error) {
|
||||
query := s.db.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ?", userID).
|
||||
Model(&ApiKey{})
|
||||
|
||||
var apiKeys []ApiKey
|
||||
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
return apiKeys, pagination, nil
|
||||
}
|
||||
|
||||
func (s *Service) CreateApiKey(ctx context.Context, userID string, input apiKeyCreateDto) (ApiKey, string, error) {
|
||||
// Check if expiration is in the future
|
||||
if !input.ExpiresAt.ToTime().After(time.Now()) {
|
||||
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
|
||||
}
|
||||
|
||||
// Generate a secure random API key
|
||||
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
apiKey := ApiKey{
|
||||
Name: input.Name,
|
||||
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
||||
Description: input.Description,
|
||||
ExpiresAt: input.ExpiresAt,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
err = s.db.
|
||||
WithContext(ctx).
|
||||
Create(&apiKey).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
|
||||
}
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
// Return the raw token only once - it cannot be retrieved later
|
||||
return apiKey, token, nil
|
||||
}
|
||||
|
||||
func (s *Service) RenewApiKey(ctx context.Context, userID, apiKeyID string, expiration time.Time) (ApiKey, string, error) {
|
||||
// Check if expiration is in the future
|
||||
if !expiration.After(time.Now()) {
|
||||
return ApiKey{}, "", &common.APIKeyExpirationDateError{}
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer tx.Rollback()
|
||||
|
||||
var apiKey ApiKey
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Model(&ApiKey{}).
|
||||
Where("id = ? AND user_id = ?", apiKeyID, userID).
|
||||
First(&apiKey).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ApiKey{}, "", &common.APIKeyNotFoundError{}
|
||||
}
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
// Only allow renewal if the key has already expired
|
||||
if apiKey.ExpiresAt.ToTime().After(time.Now()) {
|
||||
return ApiKey{}, "", &common.APIKeyNotExpiredError{}
|
||||
}
|
||||
|
||||
// Generate a secure random API key
|
||||
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
apiKey.Key = utils.CreateSha256Hash(token)
|
||||
apiKey.ExpiresAt = datatype.DateTime(expiration)
|
||||
|
||||
err = tx.WithContext(ctx).Save(&apiKey).Error
|
||||
if err != nil {
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return ApiKey{}, "", err
|
||||
}
|
||||
|
||||
return apiKey, token, nil
|
||||
}
|
||||
|
||||
func (s *Service) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
|
||||
var apiKey ApiKey
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Where("id = ? AND user_id = ?", apiKeyID, userID).
|
||||
Delete(&apiKey).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &common.APIKeyNotFoundError{}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
|
||||
if apiKey == "" {
|
||||
return model.User{}, &common.NoAPIKeyProvidedError{}
|
||||
}
|
||||
|
||||
if s.staticApiKey != "" && apiKey == s.staticApiKey {
|
||||
return s.initStaticApiKeyUser(ctx)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
hashedKey := utils.CreateSha256Hash(apiKey)
|
||||
|
||||
var key ApiKey
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Model(&ApiKey{}).
|
||||
Clauses(clause.Returning{}).
|
||||
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
|
||||
Updates(&ApiKey{
|
||||
LastUsedAt: new(datatype.DateTime(now)),
|
||||
}).
|
||||
Preload("User").
|
||||
First(&key).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, &common.InvalidAPIKeyError{}
|
||||
}
|
||||
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
return key.User, nil
|
||||
}
|
||||
|
||||
func (s *Service) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]ApiKey, error) {
|
||||
var keys []ApiKey
|
||||
now := time.Now()
|
||||
cutoff := now.AddDate(0, 0, daysAhead)
|
||||
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false).
|
||||
Find(&keys).
|
||||
Error
|
||||
|
||||
return keys, err
|
||||
}
|
||||
|
||||
// MarkExpirationEmailSent records that the expiration notification email was sent for the given API key
|
||||
func (s *Service) MarkExpirationEmailSent(ctx context.Context, apiKeyID string) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Model(&ApiKey{}).
|
||||
Where("id = ?", apiKeyID).
|
||||
Update("expiration_email_sent", true).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *Service) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
|
||||
err = s.db.
|
||||
WithContext(ctx).
|
||||
First(&user, "id = ?", common.StaticApiKeyUserID).
|
||||
Error
|
||||
|
||||
if err == nil {
|
||||
return user, nil
|
||||
}
|
||||
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
user = model.User{
|
||||
Base: model.Base{
|
||||
ID: common.StaticApiKeyUserID,
|
||||
},
|
||||
FirstName: "Static API User",
|
||||
Username: "static-api-user-" + usernameSuffix,
|
||||
DisplayName: "Static API User",
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
err = s.db.
|
||||
WithContext(ctx).
|
||||
Create(&user).
|
||||
Error
|
||||
|
||||
return user, err
|
||||
}
|
||||
|
||||
func (s *Service) deleteStaticApiKeyUser(ctx context.Context) error {
|
||||
return s.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.User{}, "id = ?", common.StaticApiKeyUserID).
|
||||
Error
|
||||
}
|
||||
64
backend/internal/appconfig/app_config_middleware.go
Normal file
64
backend/internal/appconfig/app_config_middleware.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// appConfigCtxKey is the context key used to store the AppConfigResolver in the http.Request's context
|
||||
type appConfigCtxKey struct{}
|
||||
|
||||
type appConfigResolver func(ctx context.Context) (*AppConfigModel, error)
|
||||
|
||||
// AppConfigMiddleware is a Gin middleware that makes the application configuration available to all downstream handlers through the request's context
|
||||
type AppConfigMiddleware struct {
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewAppConfigMiddleware(appConfigService *AppConfigService) *AppConfigMiddleware {
|
||||
return &AppConfigMiddleware{
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
}
|
||||
|
||||
// Add returns a Gin middleware that stores an AppConfigResolver in the http.Request's context
|
||||
// The resolver loads the application configuration lazily on the first call and caches it for the duration of the request
|
||||
func (m *AppConfigMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
reqCtx := c.Request.Context()
|
||||
|
||||
// Create a cache for each request in the middleware's scope, so it's unique per each request
|
||||
var (
|
||||
once sync.Once
|
||||
cfg *AppConfigModel
|
||||
err error
|
||||
)
|
||||
// Note: the resolver accepts a context argument, it doesn't use the request's own
|
||||
// This can be used for example for tracing
|
||||
resolver := appConfigResolver(func(ctx context.Context) (*AppConfigModel, error) {
|
||||
once.Do(func() {
|
||||
cfg, err = m.appConfigService.GetConfig(ctx)
|
||||
})
|
||||
return cfg, err
|
||||
})
|
||||
|
||||
// Store the resolver in the request's context
|
||||
c.Request = c.Request.WithContext(context.WithValue(reqCtx, appConfigCtxKey{}, resolver))
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// FromCtx retrieves the app config from the context
|
||||
func FromCtx(ctx context.Context) (*AppConfigModel, error) {
|
||||
resolver, ok := ctx.Value(appConfigCtxKey{}).(appConfigResolver)
|
||||
if !ok || resolver == nil {
|
||||
// Indicates a development-time error
|
||||
return nil, errors.New("middleware AppConfigMiddleware was not registered for the handler")
|
||||
}
|
||||
|
||||
return resolver(ctx)
|
||||
}
|
||||
112
backend/internal/appconfig/appconfig_actor.go
Normal file
112
backend/internal/appconfig/appconfig_actor.go
Normal file
@@ -0,0 +1,112 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// The AppConfig singleton actor maintains the dynamic configuration for the Pocket ID cluster
|
||||
// Instances of Pocket ID should bootstrap the AppConfig's actor upon startup to ensure the config is loaded (and migrated if needed)
|
||||
// After startup, Peek can be used for read-only operations such as retrieving the config or listing it
|
||||
|
||||
// AppConfigActorType is the actor type for the AppConfig actor
|
||||
const AppConfigActorType = "AppConfig"
|
||||
|
||||
// appConfigActor is a singleton actor that manages the dynamic app configuration
|
||||
type appConfigActor struct {
|
||||
log *slog.Logger
|
||||
client actor.Client[*AppConfigModel]
|
||||
}
|
||||
|
||||
// appConfigActorBootstrap is the type for the payload of the init method
|
||||
type appConfigActorBootstrap struct {
|
||||
LegacyConfig map[string]string
|
||||
}
|
||||
|
||||
// NewAppConfigActor allocates a new AppConfig actor
|
||||
// It satisfies actor.Factory
|
||||
func NewAppConfigActor(actorID string, service *actor.Service) actor.Actor {
|
||||
log := slog.
|
||||
With(
|
||||
slog.String("scope", "actor"),
|
||||
slog.String("actorType", AppConfigActorType),
|
||||
slog.String("actorID", actorID),
|
||||
)
|
||||
|
||||
log.Info("AppConfig actor created")
|
||||
|
||||
return &appConfigActor{
|
||||
log: log,
|
||||
client: actor.NewActorClient[*AppConfigModel](AppConfigActorType, actorID, service),
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap implements actor.ActorBootstrapper for the singleton actor
|
||||
func (a *appConfigActor) Bootstrap(parentCtx context.Context, data actor.Envelope) error {
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// If we already have a state, nothing else to do
|
||||
if state != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if the request data contains legacy config to init from
|
||||
if data != nil {
|
||||
payload := appConfigActorBootstrap{}
|
||||
err = data.Decode(&payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method 'init': %w", err)
|
||||
}
|
||||
|
||||
if len(payload.LegacyConfig) > 0 {
|
||||
state, err = fromLegacyConfig(payload.LegacyConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request body is not valid for method 'init': LegacyConfig property could not be parsed: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we still have no state, generate a new default config
|
||||
if state == nil {
|
||||
state = getDefaultConfig()
|
||||
}
|
||||
|
||||
// Save the updated state
|
||||
ctx, cancel = context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
err = a.client.SetState(ctx, state, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving actor state: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *appConfigActor) Peek(parentCtx context.Context, method string, data actor.Envelope) (any, error) {
|
||||
// Only supported method is "get"
|
||||
if method != "get" {
|
||||
return nil, common.ErrUnsupportedActorMethod{Method: method}
|
||||
}
|
||||
|
||||
// Load the actor state
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
state, err := a.client.GetState(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving actor state: %w", err)
|
||||
}
|
||||
|
||||
// Return the state
|
||||
return state, nil
|
||||
}
|
||||
47
backend/internal/appconfig/legacy_config.go
Normal file
47
backend/internal/appconfig/legacy_config.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
// loadLegacyConfig loads the legacy config from the database
|
||||
// This was migrated to the "config_migrated" key in the kv table
|
||||
func LoadLegacyConfig(ctx context.Context, db *gorm.DB) (map[string]string, error) {
|
||||
// Retrieve the migrated config from the kv table
|
||||
row := model.KV{
|
||||
Key: "config_migrated",
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
err := db.WithContext(ctx).First(&row).Error
|
||||
switch {
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// There's no migrated config in the database, nothing to do
|
||||
return nil, nil
|
||||
case err != nil:
|
||||
return nil, fmt.Errorf("failed to load migrated config from the database: %w", err)
|
||||
case row.Value == nil || len(*row.Value) == 0:
|
||||
// Also no migrated config, nothing to do
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// The value is a JSON-encoded dictionary
|
||||
res := map[string]string{}
|
||||
err = json.Unmarshal([]byte(*row.Value), &res)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error parsing migrated config: %w", err)
|
||||
}
|
||||
|
||||
if len(res) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
271
backend/internal/appconfig/model.go
Normal file
271
backend/internal/appconfig/model.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/go-kit/utils"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
type AppConfigModel struct {
|
||||
// General
|
||||
AppName AppConfigValue `json:"appName" public:"true"`
|
||||
SessionDuration AppConfigValue `json:"sessionDuration" type:"int"` // In minutes
|
||||
HomePageURL AppConfigValue `json:"homePageUrl" public:"true"`
|
||||
EmailsVerified AppConfigValue `json:"emailsVerified" type:"bool"`
|
||||
AccentColor AppConfigValue `json:"accentColor" public:"true"`
|
||||
DisableAnimations AppConfigValue `json:"disableAnimations" type:"bool" public:"true"`
|
||||
AllowOwnAccountEdit AppConfigValue `json:"allowOwnAccountEdit" type:"bool" public:"true"`
|
||||
AllowUserSignups AppConfigValue `json:"allowUserSignups" public:"true"`
|
||||
|
||||
SignupDefaultUserGroupIDs AppConfigValue `json:"signupDefaultUserGroupIDs"` // JSON-encoded array of strings
|
||||
SignupDefaultCustomClaims AppConfigValue `json:"signupDefaultCustomClaims"` // JSON-encoded array of {key:string,value:string}
|
||||
// Email
|
||||
RequireUserEmail AppConfigValue `json:"requireUserEmail" type:"bool" public:"true"`
|
||||
SmtpHost AppConfigValue `json:"smtpHost"`
|
||||
SmtpPort AppConfigValue `json:"smtpPort"`
|
||||
SmtpFrom AppConfigValue `json:"smtpFrom"`
|
||||
SmtpUser AppConfigValue `json:"smtpUser"`
|
||||
SmtpPassword AppConfigValue `json:"smtpPassword" sensitive:"true"`
|
||||
SmtpTls AppConfigValue `json:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigValue `json:"smtpSkipCertVerify" type:"bool"`
|
||||
EmailLoginNotificationEnabled AppConfigValue `json:"emailLoginNotificationEnabled" type:"bool"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigValue `json:"emailOneTimeAccessAsUnauthenticatedEnabled" type:"bool" public:"true"`
|
||||
EmailOneTimeAccessAsAdminEnabled AppConfigValue `json:"emailOneTimeAccessAsAdminEnabled" type:"bool" public:"true"`
|
||||
EmailApiKeyExpirationEnabled AppConfigValue `json:"emailApiKeyExpirationEnabled" type:"bool"`
|
||||
EmailVerificationEnabled AppConfigValue `json:"emailVerificationEnabled" type:"bool" public:"true"`
|
||||
// LDAP
|
||||
LdapEnabled AppConfigValue `json:"ldapEnabled" type:"bool" public:"true"`
|
||||
LdapUrl AppConfigValue `json:"ldapUrl"`
|
||||
LdapBindDn AppConfigValue `json:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigValue `json:"ldapBindPassword" sensitive:"true"`
|
||||
LdapBase AppConfigValue `json:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigValue `json:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigValue `json:"ldapUserGroupSearchFilter"`
|
||||
LdapSkipCertVerify AppConfigValue `json:"ldapSkipCertVerify" type:"bool"`
|
||||
LdapAttributeUserUniqueIdentifier AppConfigValue `json:"ldapAttributeUserUniqueIdentifier"`
|
||||
LdapAttributeUserUsername AppConfigValue `json:"ldapAttributeUserUsername"`
|
||||
LdapAttributeUserEmail AppConfigValue `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName AppConfigValue `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName AppConfigValue `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName AppConfigValue `json:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture AppConfigValue `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember AppConfigValue `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigValue `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName AppConfigValue `json:"ldapAttributeGroupName"`
|
||||
LdapAdminGroupName AppConfigValue `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigValue `json:"ldapSoftDeleteUsers" type:"bool"`
|
||||
}
|
||||
|
||||
// AppConfigValue holds a value
|
||||
type AppConfigValue string
|
||||
|
||||
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
|
||||
func (a AppConfigValue) IsTrue() bool {
|
||||
return utils.IsTruthy(string(a))
|
||||
}
|
||||
|
||||
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
|
||||
func (a AppConfigValue) AsDurationMinutes() time.Duration {
|
||||
val, err := strconv.Atoi(string(a))
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return time.Duration(val) * time.Minute
|
||||
}
|
||||
|
||||
// String implements fmt.Stringer
|
||||
func (a AppConfigValue) String() string {
|
||||
return string(a)
|
||||
}
|
||||
|
||||
func getDefaultConfig() *AppConfigModel {
|
||||
// Values are the default ones
|
||||
return &AppConfigModel{
|
||||
// General
|
||||
AppName: "Pocket ID",
|
||||
SessionDuration: "60",
|
||||
HomePageURL: "/settings/account",
|
||||
EmailsVerified: "false",
|
||||
DisableAnimations: "false",
|
||||
AllowOwnAccountEdit: "true",
|
||||
AllowUserSignups: "disabled",
|
||||
SignupDefaultUserGroupIDs: "[]",
|
||||
SignupDefaultCustomClaims: "[]",
|
||||
AccentColor: "default",
|
||||
// Email
|
||||
RequireUserEmail: "true",
|
||||
SmtpHost: "",
|
||||
SmtpPort: "",
|
||||
SmtpFrom: "",
|
||||
SmtpUser: "",
|
||||
SmtpPassword: "",
|
||||
SmtpTls: "none",
|
||||
SmtpSkipCertVerify: "false",
|
||||
EmailLoginNotificationEnabled: "false",
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled: "false",
|
||||
EmailOneTimeAccessAsAdminEnabled: "false",
|
||||
EmailApiKeyExpirationEnabled: "false",
|
||||
EmailVerificationEnabled: "false",
|
||||
// LDAP
|
||||
LdapEnabled: "false",
|
||||
LdapUrl: "",
|
||||
LdapBindDn: "",
|
||||
LdapBindPassword: "",
|
||||
LdapBase: "",
|
||||
LdapUserSearchFilter: "(objectClass=person)",
|
||||
LdapUserGroupSearchFilter: "(objectClass=groupOfNames)",
|
||||
LdapSkipCertVerify: "false",
|
||||
LdapAttributeUserUniqueIdentifier: "",
|
||||
LdapAttributeUserUsername: "",
|
||||
LdapAttributeUserEmail: "",
|
||||
LdapAttributeUserFirstName: "",
|
||||
LdapAttributeUserLastName: "",
|
||||
LdapAttributeUserDisplayName: "cn",
|
||||
LdapAttributeUserProfilePicture: "",
|
||||
LdapAttributeGroupMember: "member",
|
||||
LdapAttributeGroupUniqueIdentifier: "",
|
||||
LdapAttributeGroupName: "",
|
||||
LdapAdminGroupName: "",
|
||||
LdapSoftDeleteUsers: "true",
|
||||
}
|
||||
}
|
||||
|
||||
// fromLegacyConfig builds an appConfigModel from a legacy config map
|
||||
// The map's keys correspond to the "json" tags on appConfigModel, and all values are strings that are cast to each field's type
|
||||
// Keys that are missing (or have an empty value) retain the default value
|
||||
func fromLegacyConfig(legacyCfg map[string]string) (*AppConfigModel, error) {
|
||||
// Start from the default configuration, then override with the values from the legacy config
|
||||
dest := getDefaultConfig()
|
||||
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Look up the value in the legacy config
|
||||
// If the key is missing or the value is empty, we keep the default value
|
||||
value, ok := legacyCfg[key]
|
||||
if !ok || value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Cast the string value to the field's type
|
||||
fv := rv.Field(i)
|
||||
switch fv.Kind() { //nolint:exhaustive
|
||||
case reflect.String:
|
||||
fv.SetString(value)
|
||||
case reflect.Bool:
|
||||
fv.SetBool(utils.IsTruthy(value))
|
||||
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
|
||||
n, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse integer value for key '%s': %w", key, err)
|
||||
}
|
||||
fv.SetInt(n)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported field type '%s' for key '%s'", fv.Kind(), key)
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
|
||||
// Replace updates every configuration property with the values from the input DTO
|
||||
// An empty string value resets the corresponding property to its default value
|
||||
func (m *AppConfigModel) Replace(input dto.AppConfigUpdateDto) error {
|
||||
// Collect the values from the input DTO into a map, keyed by the "json" tag
|
||||
inRv := reflect.ValueOf(input)
|
||||
inRt := inRv.Type()
|
||||
values := make(map[string]string, inRt.NumField())
|
||||
for i := range inRt.NumField() {
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(inRt.Field(i).Tag.Get("json"), ",")
|
||||
values[key] = inRv.Field(i).String()
|
||||
}
|
||||
|
||||
// Iterate through all the properties, setting each one from the input
|
||||
// Properties that are missing from the input or have an empty value are reset to their default
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
rv := reflect.ValueOf(m).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
|
||||
value, ok := values[key]
|
||||
if !ok || value == "" {
|
||||
value = defaults.Field(i).String()
|
||||
}
|
||||
|
||||
rv.Field(i).SetString(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update sets configuration properties from the provided key-value pairs
|
||||
// Keys correspond to the "json" tags on the model
|
||||
// An empty string value resets the property to its default value
|
||||
func (m *AppConfigModel) Update(keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
rv := reflect.ValueOf(m).Elem()
|
||||
rt := rv.Type()
|
||||
defaults := reflect.ValueOf(getDefaultConfig()).Elem()
|
||||
|
||||
// Iterate through the key-value pairs
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
key := keysAndValues[i-1]
|
||||
value := keysAndValues[i]
|
||||
|
||||
// Find the field in the struct whose "json" tag matches
|
||||
fieldIdx := -1
|
||||
for j := range rt.NumField() {
|
||||
// Separate the key (before the comma) from any optional attributes after
|
||||
tagValue, _, _ := strings.Cut(rt.Field(j).Tag.Get("json"), ",")
|
||||
if tagValue == key {
|
||||
fieldIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
if fieldIdx < 0 {
|
||||
return AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
// An empty string means we use the default value for the property
|
||||
if value == "" {
|
||||
value = defaults.Field(fieldIdx).String()
|
||||
}
|
||||
|
||||
rv.Field(fieldIdx).SetString(value)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type AppConfigKeyNotFoundError struct {
|
||||
field string
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
|
||||
_, ok := errors.AsType[*AppConfigKeyNotFoundError](target)
|
||||
return ok
|
||||
}
|
||||
201
backend/internal/appconfig/model_test.go
Normal file
201
backend/internal/appconfig/model_test.go
Normal file
@@ -0,0 +1,201 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
)
|
||||
|
||||
// dtoWithMarkerValues returns a DTO where every field is set to a unique, non-empty marker derived from its "json" key, so we can assert each value lands in the right place.
|
||||
func dtoWithMarkerValues() dto.AppConfigUpdateDto {
|
||||
var input dto.AppConfigUpdateDto
|
||||
rv := reflect.ValueOf(&input).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
rv.Field(i).SetString("marker-" + key)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Replace(t *testing.T) {
|
||||
t.Run("populates every property from the DTO", func(t *testing.T) {
|
||||
input := dtoWithMarkerValues()
|
||||
|
||||
var m AppConfigModel
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Each model property must hold the marker built from its own "json" key.
|
||||
// This also asserts that the model and the DTO share the same set of keys.
|
||||
rv := reflect.ValueOf(&m).Elem()
|
||||
rt := rv.Type()
|
||||
for i := range rt.NumField() {
|
||||
key, _, _ := strings.Cut(rt.Field(i).Tag.Get("json"), ",")
|
||||
assert.Equalf(t, "marker-"+key, rv.Field(i).String(), "property %s (key %q)", rt.Field(i).Name, key)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty values fall back to their default", func(t *testing.T) {
|
||||
defaults := getDefaultConfig()
|
||||
|
||||
// Start from all-markers, then blank out a few properties whose default is non-empty
|
||||
input := dtoWithMarkerValues()
|
||||
input.AppName = ""
|
||||
input.SessionDuration = ""
|
||||
input.SmtpTls = ""
|
||||
input.LdapUserSearchFilter = ""
|
||||
|
||||
var m AppConfigModel
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Blanked properties are reset to their default
|
||||
assert.Equal(t, defaults.AppName, m.AppName)
|
||||
assert.Equal(t, defaults.SessionDuration, m.SessionDuration)
|
||||
assert.Equal(t, defaults.SmtpTls, m.SmtpTls)
|
||||
assert.Equal(t, defaults.LdapUserSearchFilter, m.LdapUserSearchFilter)
|
||||
|
||||
// A property that was provided keeps the provided value
|
||||
assert.Equal(t, "marker-homePageUrl", m.HomePageURL)
|
||||
})
|
||||
|
||||
t.Run("an empty DTO resets every property to its default", func(t *testing.T) {
|
||||
// Pre-populate with junk to prove Replace overwrites existing state
|
||||
m := AppConfigModel{
|
||||
AppName: "Custom Name",
|
||||
LdapEnabled: "true",
|
||||
SmtpHost: "smtp.example.com",
|
||||
}
|
||||
|
||||
err := m.Replace(dto.AppConfigUpdateDto{})
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, *getDefaultConfig(), m)
|
||||
})
|
||||
|
||||
t.Run("provided values overwrite existing non-default values", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
m.AppName = "Old Name"
|
||||
m.LdapEnabled = "true"
|
||||
|
||||
input := dto.AppConfigUpdateDto{}
|
||||
input.AppName = "New Name"
|
||||
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Explicitly provided value wins
|
||||
assert.Equal(t, "New Name", m.AppName)
|
||||
// Everything else in the DTO was empty, so it is reset to the default
|
||||
assert.Equal(t, getDefaultConfig().LdapEnabled, m.LdapEnabled)
|
||||
})
|
||||
|
||||
t.Run("stores raw string values without type coercion", func(t *testing.T) {
|
||||
input := dto.AppConfigUpdateDto{}
|
||||
input.SessionDuration = "120" // int-tagged property
|
||||
input.LdapEnabled = "true" // bool-tagged property
|
||||
|
||||
var m AppConfigModel
|
||||
err := m.Replace(input)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "120", m.SessionDuration)
|
||||
assert.Equal(t, "true", m.LdapEnabled)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppConfigModel_Update(t *testing.T) {
|
||||
t.Run("updates a single property", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("appName", "My App")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "My App", m.AppName)
|
||||
})
|
||||
|
||||
t.Run("updates multiple properties and leaves others untouched", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("appName", "My App", "homePageUrl", "/home", "ldapEnabled", "true")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "My App", m.AppName)
|
||||
assert.Equal(t, "/home", m.HomePageURL)
|
||||
assert.Equal(t, "true", m.LdapEnabled)
|
||||
// A property that was not part of the update keeps its previous value
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("an empty value resets the property to its default", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
m.SmtpTls = "tls" // default is "none"
|
||||
m.SessionDuration = "120" // default is "60"
|
||||
|
||||
err := m.Update("smtpTls", "", "sessionDuration", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, getDefaultConfig().SmtpTls, m.SmtpTls)
|
||||
assert.Equal(t, getDefaultConfig().SessionDuration, m.SessionDuration)
|
||||
})
|
||||
|
||||
t.Run("stores raw string values without type coercion", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("sessionDuration", "120", "disableAnimations", "true")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "120", m.SessionDuration)
|
||||
assert.Equal(t, "true", m.DisableAnimations)
|
||||
})
|
||||
|
||||
t.Run("later value wins for a repeated key", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("appName", "First", "appName", "Second")
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "Second", m.AppName)
|
||||
})
|
||||
|
||||
t.Run("no arguments is a no-op", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
before := *m
|
||||
|
||||
err := m.Update()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, before, *m)
|
||||
})
|
||||
|
||||
t.Run("an odd number of arguments returns an error", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
before := *m
|
||||
|
||||
err := m.Update("appName")
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, "invalid number of arguments received")
|
||||
|
||||
// The config must not have been modified
|
||||
assert.Equal(t, before, *m)
|
||||
})
|
||||
|
||||
t.Run("an unknown key returns AppConfigKeyNotFoundError", func(t *testing.T) {
|
||||
m := getDefaultConfig()
|
||||
|
||||
err := m.Update("thisKeyDoesNotExist", "value")
|
||||
require.Error(t, err)
|
||||
assert.EqualError(t, err, "cannot find config key 'thisKeyDoesNotExist'")
|
||||
|
||||
notFound, ok := errors.AsType[AppConfigKeyNotFoundError](err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, "thisKeyDoesNotExist", notFound.field)
|
||||
})
|
||||
}
|
||||
301
backend/internal/appconfig/service.go
Normal file
301
backend/internal/appconfig/service.go
Normal file
@@ -0,0 +1,301 @@
|
||||
package appconfig
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/actor"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type AppConfigService struct {
|
||||
actSvc *actor.Service
|
||||
envConfig *AppConfigModel
|
||||
}
|
||||
|
||||
func NewService(ctx context.Context, actors *local.Host, db *gorm.DB) (service *AppConfigService, err error) {
|
||||
service = &AppConfigService{}
|
||||
|
||||
// If the UI config is disabled, we do not need to init the config actor
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
service.envConfig, err = service.loadDbConfigFromEnv()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading app config from the env: %w", err)
|
||||
}
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// Note: we need to assign to the "err" variable in this method (for tracing), do not inline this into the "if"
|
||||
ctx, span := tracing.Start(ctx, "pocketid.appconfig.init")
|
||||
defer tracing.End(span, err)
|
||||
|
||||
// Load the legacy config if any, which we need to send to the actor as bootstrap data
|
||||
legacyCfg, err := LoadLegacyConfig(ctx, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error loading legacy config: %w", err)
|
||||
}
|
||||
|
||||
// Register the AppConfig actor
|
||||
// This is a singleton actor and it's bootstrapped with the legacy config if present
|
||||
bootstrapData := &appConfigActorBootstrap{
|
||||
LegacyConfig: legacyCfg,
|
||||
}
|
||||
err = actors.RegisterSingletonActor(
|
||||
AppConfigActorType, NewAppConfigActor,
|
||||
local.WithBootstrapData(bootstrapData),
|
||||
local.WithIdleTimeout(-1), // Disable idle timeout for this actor
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering the %s actor: %w", AppConfigActorType, err)
|
||||
}
|
||||
|
||||
service.actSvc = actors.Service()
|
||||
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetConfig returns the application configuration
|
||||
// Important: Treat the object as read-only: do not modify its properties directly!
|
||||
func (s *AppConfigService) GetConfig(parentCtx context.Context) (*AppConfigModel, error) {
|
||||
// If the UI config is disabled, only load from the env
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return s.envConfig, nil
|
||||
}
|
||||
|
||||
// Retrieve the config from the actor
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Second)
|
||||
defer cancel()
|
||||
res, err := s.actSvc.Peek(ctx, AppConfigActorType, actor.SingletonActorID, "get", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error retrieving config from actor: %w", err)
|
||||
}
|
||||
if res == nil {
|
||||
return nil, errors.New("config actor response was empty")
|
||||
}
|
||||
|
||||
var cfg AppConfigModel
|
||||
err = res.Decode(&cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error decoding config actor response: %w", err)
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
// Perform an "upsert" if the key already exists, replacing the value
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).
|
||||
Create(&dbUpdate).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update config in database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return nil, &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := getDefaultConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
|
||||
for field := range rt.Fields() {
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
if value == "" {
|
||||
// Ignore errors here as we know the key exists
|
||||
defaultValue, _ := defaultCfg.FieldByKey(key)
|
||||
err = cfg.UpdateField(key, defaultValue)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
// Return the updated config
|
||||
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// UpdateAppConfigValues updates the application configuration values in the database.
|
||||
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
||||
// Count of keysAndValues must be even
|
||||
if len(keysAndValues)%2 != 0 {
|
||||
return errors.New("invalid number of arguments received")
|
||||
}
|
||||
|
||||
if common.EnvConfig.UiConfigDisabled {
|
||||
return &common.UiConfigDisabledError{}
|
||||
}
|
||||
|
||||
// Start the transaction
|
||||
tx, err := s.updateAppConfigStartTransaction(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
|
||||
// Re-load the config from the database to be sure we have the correct data
|
||||
cfg, err := s.loadDbConfigInternal(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload config from database: %w", err)
|
||||
}
|
||||
|
||||
defaultCfg := getDefaultDbConfig()
|
||||
|
||||
// Iterate through all the fields to update
|
||||
// We update the in-memory data (in the cfg struct) and collect values to update in the database
|
||||
// (Note the += 2, as we are iterating through key-value pairs)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
|
||||
for i := 1; i < len(keysAndValues); i += 2 {
|
||||
key := keysAndValues[i-1]
|
||||
value := keysAndValues[i]
|
||||
|
||||
// Ensure that the field is valid
|
||||
// We do this by grabbing the default value
|
||||
var defaultValue string
|
||||
defaultValue, err := defaultCfg.FieldByKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// Update the in-memory config value
|
||||
// If the new value is an empty string, then we set the in-memory value to the default one
|
||||
if value == "" {
|
||||
err = cfg.UpdateField(key, defaultValue)
|
||||
} else {
|
||||
err = cfg.UpdateField(key, value)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
|
||||
}
|
||||
|
||||
// We always save "value" which can be an empty string
|
||||
dbUpdate = append(dbUpdate, model.AppConfigVariable{
|
||||
Key: key,
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
// Update the values in the database
|
||||
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Commit the changes to the DB, then finally save the updated config in the object
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
s.dbConfig.Store(cfg)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||
}
|
||||
|
||||
func (s *AppConfigService) loadDbConfigFromEnv() (*AppConfigModel, error) {
|
||||
// First, start from the default configuration
|
||||
dest := getDefaultConfig()
|
||||
|
||||
// Iterate through each field
|
||||
rt := reflect.ValueOf(dest).Elem().Type()
|
||||
rv := reflect.ValueOf(dest).Elem()
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the key and internal tag values
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
|
||||
|
||||
// Set the value if it's set
|
||||
value, ok := os.LookupEnv(envVarName)
|
||||
if ok {
|
||||
rv.Field(i).Set(reflect.ValueOf(value))
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's sensitive, we also allow reading from file
|
||||
if attrs == "sensitive" {
|
||||
fileName := os.Getenv(envVarName + "_FILE")
|
||||
if fileName != "" {
|
||||
// #nosec G703 - Value is provided by admin
|
||||
b, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
|
||||
}
|
||||
|
||||
rv.Field(i).Set(reflect.ValueOf(string(b)))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dest, nil
|
||||
}
|
||||
18
backend/internal/appconfig/testing_unit.go
Normal file
18
backend/internal/appconfig/testing_unit.go
Normal file
@@ -0,0 +1,18 @@
|
||||
//go:build unit
|
||||
|
||||
// This file contains utils for unit tests and it's only built when the "unit" tag is set
|
||||
package appconfig
|
||||
|
||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||
func NewTestAppConfigService(config *AppConfigModel) *AppConfigService {
|
||||
if config == nil {
|
||||
// If there's no config, set the default one
|
||||
config = getDefaultConfig()
|
||||
}
|
||||
|
||||
service := &AppConfigService{
|
||||
envConfig: config,
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
171
backend/internal/bootstrap/actors_bootstrap.go
Normal file
171
backend/internal/bootstrap/actors_bootstrap.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
"github.com/italypaleale/francis/components/postgres"
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||
)
|
||||
|
||||
type NewActorsOpts struct {
|
||||
SQLite *sql.DB
|
||||
Postgres *pgxpool.Pool
|
||||
|
||||
EnvConfig *common.EnvConfigSchema
|
||||
InstanceID string
|
||||
HttpClient *http.Client
|
||||
DB *gorm.DB
|
||||
FileStorage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) {
|
||||
log := slog.Default()
|
||||
|
||||
// Derive a PSK from the global encryption key
|
||||
// The runtime PSK derives the cluster CA used for host-to-host mTLS
|
||||
psk, err := o.getPSK()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to derive PSK: %w", err)
|
||||
}
|
||||
|
||||
// Options for the host
|
||||
opts := []local.HostOption{
|
||||
local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)),
|
||||
local.WithLogger(log.With("scope", "actor-host")),
|
||||
local.WithRuntimePSKs(psk),
|
||||
local.WithShutdownGracePeriod(10 * time.Second),
|
||||
}
|
||||
|
||||
// Add the database connection
|
||||
providerOpt, err := o.getProvider()
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
opts = append(opts, providerOpt)
|
||||
|
||||
// Create a new actor host
|
||||
h, err := local.NewHost(opts...)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create actor host: %w", err)
|
||||
}
|
||||
|
||||
// Add all cron jobs
|
||||
err = o.registerCronJobs(h)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Add the rate limiters
|
||||
rateLimiters, err := o.registerRateLimiters(h)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Bind a service for each rate limiter so the middleware can invoke them
|
||||
rateLimitServices := make(map[string]*ratelimit.RateLimitService, len(rateLimiters))
|
||||
for name, rl := range rateLimiters {
|
||||
rateLimitServices[name] = rl.Service(h.Service())
|
||||
}
|
||||
|
||||
return h, rateLimitServices, nil
|
||||
}
|
||||
|
||||
// Derive a PSK from the global encryption key
|
||||
func (o *NewActorsOpts) getPSK() ([]byte, error) {
|
||||
// This is tied to the instance ID of the Pocket ID deployment/cluster
|
||||
// Note: changing the key derivation or the seed is a breaking change
|
||||
return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk/"+o.InstanceID)
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) getProvider() (local.HostOption, error) {
|
||||
switch {
|
||||
case o.Postgres != nil && o.SQLite != nil:
|
||||
return nil, errors.New("cannot have both Postgres and SQLite connections")
|
||||
case o.Postgres != nil:
|
||||
return local.WithPostgresProvider(postgres.PostgresProviderOptions{
|
||||
DB: o.Postgres,
|
||||
}), nil
|
||||
case o.SQLite != nil:
|
||||
return local.WithSQLiteProvider(local.SQLiteProviderOptions{
|
||||
DB: o.SQLite,
|
||||
}), nil
|
||||
default:
|
||||
return nil, errors.New("one of Postgres and SQLite must be set")
|
||||
}
|
||||
}
|
||||
|
||||
func (o *NewActorsOpts) registerCronJobs(host *local.Host) (err error) {
|
||||
// In test mode, we do not register anything
|
||||
if common.EnvConfig.AppEnv == "test" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Register the analytics job
|
||||
analyticsJob, err := job.GetAnalyticsJob(o.HttpClient, o.InstanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get analytics cron job: %w", err)
|
||||
}
|
||||
|
||||
// This could be nil if analytics are disabled
|
||||
if analyticsJob != nil {
|
||||
err = host.RegisterBuiltInActor(analyticsJob)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error registering built-in actor for analytics job: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Register the file cleanup jobs
|
||||
fileCleanupJobs, err := job.GetFileCleanupJobs(o.DB, o.FileStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get file cleanup cron jobs: %w", err)
|
||||
}
|
||||
for _, j := range fileCleanupJobs {
|
||||
err = host.RegisterBuiltInActor(j)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error registering built-in actor for cleanup job: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// registerRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them
|
||||
// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment
|
||||
func (o *NewActorsOpts) registerRateLimiters(host *local.Host) (actors map[string]*ratelimit.RateLimit, err error) {
|
||||
policies := middleware.RateLimitPolicies()
|
||||
actors = make(map[string]*ratelimit.RateLimit, len(policies))
|
||||
for _, p := range policies {
|
||||
rl, err := ratelimit.New(
|
||||
p.Name,
|
||||
ratelimit.WithRate(p.Rate),
|
||||
ratelimit.WithPer(p.Per),
|
||||
ratelimit.WithBurst(p.Burst),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err)
|
||||
}
|
||||
actors[p.Name] = rl
|
||||
|
||||
err = host.RegisterBuiltInActor(rl)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("error registering built-in actor for rate limiter '%s': %w", p.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
return actors, nil
|
||||
}
|
||||
28
backend/internal/bootstrap/actors_bootstrap_test.go
Normal file
28
backend/internal/bootstrap/actors_bootstrap_test.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) {
|
||||
opts := NewActorsOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
EncryptionKey: []byte("test-encryption-key"),
|
||||
},
|
||||
// Constant value for this test
|
||||
InstanceID: "ee05c3eb-8129-47a6-a1c7-849998b6f876",
|
||||
}
|
||||
|
||||
expectedHex := "db09067fa194c3731bf77b6415a1c5d903f03d4557605ba3236b31f6eddfc8d7"
|
||||
expected, err := hex.DecodeString(expectedHex)
|
||||
require.NoError(t, err)
|
||||
|
||||
actual, err := opts.getPSK()
|
||||
require.NoError(t, err)
|
||||
require.Equalf(t, expected, actual, "actual result: %s", actual)
|
||||
}
|
||||
152
backend/internal/bootstrap/app_images_bootstrap.go
Normal file
152
backend/internal/bootstrap/app_images_bootstrap.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
// initApplicationImages copies the images from the embedded directory to the storage backend
|
||||
// and returns a map containing the detected file extensions in the application-images directory.
|
||||
//
|
||||
//nolint:gocognit
|
||||
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
|
||||
// Previous versions of images
|
||||
// If these are found, they are deleted
|
||||
legacyImageHashes := imageHashMap{
|
||||
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
|
||||
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
|
||||
}
|
||||
|
||||
sourceFiles, err := resources.FS.ReadDir("images")
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
destinationFiles, err := fileStorage.List(ctx, "application-images")
|
||||
if err != nil {
|
||||
if storage.IsNotExist(err) {
|
||||
destinationFiles = []storage.ObjectInfo{}
|
||||
} else {
|
||||
return nil, fmt.Errorf("failed to list application images: %w", err)
|
||||
}
|
||||
|
||||
}
|
||||
dstNameToExt := make(map[string]string, len(destinationFiles))
|
||||
for _, f := range destinationFiles {
|
||||
// Skip directories
|
||||
_, name := path.Split(f.Path)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||
reader, _, err := fileStorage.Open(ctx, f.Path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
continue
|
||||
}
|
||||
slog.Warn("Failed to open application image for hashing", slog.String("name", name), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
hash, err := hashStream(reader)
|
||||
reader.Close()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to hash application image", slog.String("name", name), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the file is a legacy one - if so, delete it
|
||||
if legacyImageHashes.Contains(hash) {
|
||||
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
||||
if err := fileStorage.Delete(ctx, f.Path); err != nil {
|
||||
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
dstNameToExt[nameWithoutExt] = ext
|
||||
}
|
||||
|
||||
initedPath := path.Join("application-images", ".inited")
|
||||
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
|
||||
return dstNameToExt, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to read .inited: %w", err)
|
||||
} else {
|
||||
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store .inited: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||
for _, sourceFile := range sourceFiles {
|
||||
if sourceFile.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
name := sourceFile.Name()
|
||||
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||
srcFilePath := path.Join("images", name)
|
||||
|
||||
if _, exists := dstNameToExt[nameWithoutExt]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Writing new application image", slog.String("name", name))
|
||||
srcFile, err := resources.FS.Open(srcFilePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
|
||||
}
|
||||
if err := fileStorage.Save(ctx, path.Join("application-images", name), srcFile); err != nil {
|
||||
srcFile.Close()
|
||||
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
|
||||
}
|
||||
srcFile.Close()
|
||||
dstNameToExt[nameWithoutExt] = ext
|
||||
}
|
||||
|
||||
return dstNameToExt, nil
|
||||
}
|
||||
|
||||
type imageHashMap map[string][]byte
|
||||
|
||||
func (m imageHashMap) Contains(target []byte) bool {
|
||||
if len(target) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, h := range m {
|
||||
if bytes.Equal(h, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func mustDecodeHex(str string) []byte {
|
||||
b, err := hex.DecodeString(str)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func hashStream(r io.Reader) ([]byte, error) {
|
||||
h := sha256.New()
|
||||
if _, err := io.Copy(h, r); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||
func initApplicationImages() {
|
||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||
|
||||
sourceFiles, err := resources.FS.ReadDir("images")
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
}
|
||||
|
||||
destinationFiles, err := os.ReadDir(dirPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
}
|
||||
|
||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||
for _, sourceFile := range sourceFiles {
|
||||
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
|
||||
continue
|
||||
}
|
||||
srcFilePath := path.Join("images", sourceFile.Name())
|
||||
destFilePath := path.Join(dirPath, sourceFile.Name())
|
||||
|
||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error copying file: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
|
||||
for _, destinationFile := range destinationFiles {
|
||||
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
|
||||
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
|
||||
|
||||
if sourceFileWithoutExtension == destinationFileWithoutExtension {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func getImageNameWithoutExtension(fileName string) string {
|
||||
splitted := strings.Split(fileName, ".")
|
||||
return strings.Join(splitted[:len(splitted)-1], ".")
|
||||
}
|
||||
@@ -1,19 +1,248 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
)
|
||||
|
||||
func Bootstrap() {
|
||||
initApplicationImages()
|
||||
func Bootstrap(ctx context.Context) error {
|
||||
// List of services to run
|
||||
services := make([]servicerunner.Service, 0, 3)
|
||||
shutdowns := &shutdownManager{
|
||||
fns: make([]servicerunner.Service, 0, 4),
|
||||
}
|
||||
|
||||
migrateConfigDBConnstring()
|
||||
// Initialize the observability stack, including the logger, distributed tracing, and metrics
|
||||
shutdownFns, httpClient, err := initObservability(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
|
||||
}
|
||||
shutdowns.Add(shutdownFns...)
|
||||
|
||||
db := newDatabase()
|
||||
appConfigService := service.NewAppConfigService(db)
|
||||
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||
|
||||
migrateKey()
|
||||
// Init database
|
||||
db, pg, err := NewDatabase(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
if pg != nil {
|
||||
defer func() {
|
||||
// Close the database connection pool only after the shutdown functions have run: some of them (e.g. releasing the application lock) still need to query the database.
|
||||
pg.Close()
|
||||
}()
|
||||
}
|
||||
|
||||
initRouter(db, appConfigService)
|
||||
// Load the instance ID
|
||||
// This is stored in the "kv" table, and generated on first startup
|
||||
instanceID, err := instanceid.Load(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize instance ID: %w", err)
|
||||
}
|
||||
|
||||
// Init storage
|
||||
fileStorage, err := InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err)
|
||||
}
|
||||
|
||||
// Init application images
|
||||
imageExtensions, err := initApplicationImages(ctx, fileStorage)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||
}
|
||||
|
||||
// Init the scheduler
|
||||
scheduler, err := job.NewScheduler()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create job scheduler: %w", err)
|
||||
}
|
||||
|
||||
// Init the actors
|
||||
// The actor host is created and started before the services, so services can depend on it once it's ready
|
||||
actorsOpts := NewActorsOpts{
|
||||
Postgres: pg,
|
||||
|
||||
EnvConfig: &common.EnvConfig,
|
||||
InstanceID: instanceID,
|
||||
HttpClient: httpClient,
|
||||
DB: db,
|
||||
FileStorage: fileStorage,
|
||||
}
|
||||
if pg == nil {
|
||||
actorsOpts.SQLite, err = db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err)
|
||||
}
|
||||
}
|
||||
actors, rateLimitServices, err := NewActors(actorsOpts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize actors: %w", err)
|
||||
}
|
||||
|
||||
// Run the actor host as a background service and get a "ready" signal that other services can wait on
|
||||
actorsRun, actorsReady := actorsRunServiceFn(actors)
|
||||
services = append(services, actorsRun)
|
||||
|
||||
// Create all services
|
||||
svc, err := initServices(ctx, db, instanceID, actors, httpClient, imageExtensions, fileStorage, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
services = append(services, svc.appLockService.RunRenewal)
|
||||
|
||||
// Acquire the lock from the app lock service
|
||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Until(waitUntil)):
|
||||
}
|
||||
|
||||
shutdowns.Add(func(shutdownCtx context.Context) error {
|
||||
sErr := svc.appLockService.Release(shutdownCtx)
|
||||
if sErr != nil {
|
||||
return fmt.Errorf("failed to release application lock: %w", sErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
// Register scheduled jobs, only in non-test mode
|
||||
if common.EnvConfig.AppEnv != "test" {
|
||||
err = registerScheduledJobs(ctx, db, svc, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register scheduled jobs: %w", err)
|
||||
}
|
||||
services = append(services, scheduler.Run)
|
||||
}
|
||||
|
||||
// Init the router
|
||||
// The rate-limit middleware invokes the actor host with each request's own context, so the setup context is intentionally not threaded through the router
|
||||
//nolint:contextcheck
|
||||
router, err := initRouter(db, svc, rateLimitServices)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize router: %w", err)
|
||||
}
|
||||
|
||||
// The router must wait on the actor host being ready, since the rate-limit middleware invokes actors
|
||||
services = append(services, actorsReady.Await(router))
|
||||
|
||||
// Run all background services
|
||||
// This call blocks until the context is canceled
|
||||
err = servicerunner.NewServiceRunner(services...).Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run services: %w", err)
|
||||
}
|
||||
|
||||
// Run all shutdown functions
|
||||
shutdowns.Run(ctx)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// actorsRunServiceFn wraps the actor host's Run method in a background service and returns a "ready" signal that other services can wait on
|
||||
func actorsRunServiceFn(actors *local.Host) (servicerunner.Service, *servicerunner.Ready) {
|
||||
actorsReady := servicerunner.NewReady()
|
||||
fn := func(ctx context.Context) error {
|
||||
runErrCh := make(chan error, 1)
|
||||
go func() {
|
||||
runErrCh <- actors.Run(ctx)
|
||||
}()
|
||||
|
||||
// Wait for the right signal
|
||||
select {
|
||||
case <-actors.Ready():
|
||||
// Actor host is ready, signal actorsReady
|
||||
actorsReady.Signal()
|
||||
case runErr := <-runErrCh:
|
||||
// Run returned with an error
|
||||
return runErr
|
||||
case <-ctx.Done():
|
||||
// Context canceled
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
// Now the actor host is running
|
||||
// This goroutine must stay up until the actor host returns
|
||||
// Here, context cancellation will surface through this channel too
|
||||
return <-runErrCh
|
||||
}
|
||||
|
||||
return fn, actorsReady
|
||||
}
|
||||
|
||||
func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) {
|
||||
switch common.EnvConfig.FileBackend {
|
||||
case storage.TypeFileSystem:
|
||||
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
|
||||
case storage.TypeDatabase:
|
||||
fileStorage, err = storage.NewDatabaseStorage(db)
|
||||
case storage.TypeS3:
|
||||
s3Cfg := storage.S3Config{
|
||||
Bucket: common.EnvConfig.S3Bucket,
|
||||
Region: common.EnvConfig.S3Region,
|
||||
Endpoint: common.EnvConfig.S3Endpoint,
|
||||
AccessKeyID: common.EnvConfig.S3AccessKeyID,
|
||||
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
|
||||
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
|
||||
DisableDefaultIntegrityChecks: common.EnvConfig.S3DisableDefaultIntegrityChecks,
|
||||
Root: common.EnvConfig.UploadPath,
|
||||
}
|
||||
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
|
||||
default:
|
||||
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
|
||||
}
|
||||
if err != nil {
|
||||
return fileStorage, err
|
||||
}
|
||||
|
||||
return fileStorage, nil
|
||||
}
|
||||
|
||||
type shutdownManager struct {
|
||||
fns []servicerunner.Service
|
||||
}
|
||||
|
||||
func (s *shutdownManager) Add(fns ...servicerunner.Service) {
|
||||
for _, fn := range fns {
|
||||
if fn == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
s.fns = append(s.fns, fn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *shutdownManager) Run(ctx context.Context) {
|
||||
// Cleanup functions are one-shot and must each run to completion independently, so we set WaitAll to true
|
||||
sr := servicerunner.NewServiceRunner(s.fns...)
|
||||
sr.WaitAll = true
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
err := sr.Run(shutdownCtx)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Error shutting down services", slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// Performs the migration of the database connection string
|
||||
// See: https://github.com/pocket-id/pocket-id/pull/388
|
||||
func migrateConfigDBConnstring() {
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
// Check if we're using the deprecated SqliteDBPath env var
|
||||
if common.EnvConfig.SqliteDBPath != "" {
|
||||
connString := "file:" + common.EnvConfig.SqliteDBPath + "?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate"
|
||||
common.EnvConfig.DbConnectionString = connString
|
||||
common.EnvConfig.SqliteDBPath = ""
|
||||
|
||||
log.Printf("[WARN] Env var 'SQLITE_DB_PATH' is deprecated - use 'DB_CONNECTION_STRING' instead with the value: '%s'", connString)
|
||||
}
|
||||
case common.DbProviderPostgres:
|
||||
// Check if we're using the deprecated PostgresConnectionString alias
|
||||
if common.EnvConfig.PostgresConnectionString != "" {
|
||||
common.EnvConfig.DbConnectionString = common.EnvConfig.PostgresConnectionString
|
||||
common.EnvConfig.PostgresConnectionString = ""
|
||||
|
||||
log.Print("[WARN] Env var 'POSTGRES_CONNECTION_STRING' is deprecated - use 'DB_CONNECTION_STRING' instead with the same value")
|
||||
}
|
||||
default:
|
||||
// We don't do anything here in the default case
|
||||
// This is an error, but will be handled later on
|
||||
}
|
||||
}
|
||||
@@ -1,135 +1,192 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||
sqlitekit "github.com/italypaleale/go-sql-utils/sqlite"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/jackc/pgx/v5/stdlib"
|
||||
"github.com/libtnb/sqlite"
|
||||
slogGorm "github.com/orandin/slog-gorm"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
tracingGorm "gorm.io/plugin/opentelemetry/tracing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
|
||||
)
|
||||
|
||||
func newDatabase() (db *gorm.DB) {
|
||||
db, err := connectDatabase()
|
||||
func NewDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
|
||||
db, pg, err = ConnectDatabase(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
return nil, nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get sql.DB: %v", err)
|
||||
}
|
||||
|
||||
// Choose the correct driver for the database provider
|
||||
var driver database.Driver
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
|
||||
case common.DbProviderPostgres:
|
||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||
default:
|
||||
// Should never happen at this point
|
||||
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create migration driver: %v", err)
|
||||
return nil, nil, fmt.Errorf("failed to get sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := migrateDatabase(driver); err != nil {
|
||||
log.Fatalf("failed to run migrations: %v", err)
|
||||
err = utils.MigrateDatabase(ctx, sqlDb)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return db
|
||||
return db, pg, nil
|
||||
}
|
||||
|
||||
func migrateDatabase(driver database.Driver) error {
|
||||
// Use the embedded migrations
|
||||
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
//nolint:gocognit
|
||||
func ConnectDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
// Choose the correct database provider
|
||||
var onConnFn func(conn *sql.DB)
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||
}
|
||||
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
|
||||
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
|
||||
|
||||
sqliteutil.RegisterSqliteFunctions()
|
||||
|
||||
connString, dbPath, isMemoryDB, err := sqlitekit.ParseConnectionString(common.EnvConfig.DbConnectionString, slog.Default())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
dialector = sqlite.Open(common.EnvConfig.DbConnectionString)
|
||||
|
||||
if !isMemoryDB {
|
||||
err = sqlitekit.EnsureDatabaseDir(dbPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var sqliteNetworkFilesystem bool
|
||||
sqliteNetworkFilesystem, err = sqlitekit.IsNetworkedFileSystem(filepath.Dir(dbPath))
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
slog.Warn("Failed to detect filesystem type for the SQLite database directory", slog.String("path", filepath.Dir(dbPath)), slog.Any("error", err))
|
||||
} else if sqliteNetworkFilesystem {
|
||||
slog.Warn("⚠️⚠️⚠️ SQLite databases should not be stored on a networked file system like NFS, SMB, or FUSE, as there's a risk of crashes and even database corruption", slog.String("path", filepath.Dir(dbPath)))
|
||||
}
|
||||
}
|
||||
|
||||
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||
err = sqlitekit.EnsureTempDir(filepath.Dir(dbPath), slog.Default())
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
if isMemoryDB {
|
||||
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
|
||||
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
|
||||
onConnFn = func(conn *sql.DB) {
|
||||
conn.SetMaxOpenConns(1)
|
||||
}
|
||||
}
|
||||
|
||||
dialector = sqlite.Open(connString)
|
||||
case common.DbProviderPostgres:
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||
return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||
}
|
||||
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
|
||||
|
||||
// We need a pgxpool object for francis, so we open this as a pgxpool...
|
||||
pg, err = pgxpool.New(ctx, common.EnvConfig.DbConnectionString)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create Postgres pool: %w", err)
|
||||
}
|
||||
|
||||
// ...test it with a ping...
|
||||
pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer pingCancel()
|
||||
err = pg.Ping(pingCtx)
|
||||
if err != nil {
|
||||
pg.Close()
|
||||
return nil, nil, fmt.Errorf("failed to ping Postgres database: %w", err)
|
||||
}
|
||||
|
||||
// ...then create the dialector by adapting it to *sql.DB
|
||||
dialector = postgres.New(postgres.Config{
|
||||
Conn: stdlib.OpenDBFromPool(pg),
|
||||
})
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
return nil, nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
|
||||
// Try connecting up to 3 times
|
||||
for i := 1; i <= 3; i++ {
|
||||
db, err = gorm.Open(dialector, &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: getLogger(),
|
||||
Logger: getGormLogger(),
|
||||
})
|
||||
if err == nil {
|
||||
return db, nil
|
||||
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
|
||||
|
||||
// Configure tracing and metrics
|
||||
err = db.Use(tracingGorm.NewPlugin())
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to configure tracing for DB: %w", err)
|
||||
}
|
||||
|
||||
// Invoke the onConnFn callback if any
|
||||
if onConnFn != nil {
|
||||
conn, err := db.DB()
|
||||
if err != nil {
|
||||
if pg != nil {
|
||||
pg.Close()
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed to get database connection for onConnFn callback: %w", err)
|
||||
}
|
||||
|
||||
onConnFn(conn)
|
||||
}
|
||||
|
||||
return db, pg, nil
|
||||
}
|
||||
|
||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
||||
// If we're here, the connection failed
|
||||
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
|
||||
func getLogger() logger.Interface {
|
||||
isProduction := common.EnvConfig.AppEnv == "production"
|
||||
|
||||
var logLevel logger.LogLevel
|
||||
if isProduction {
|
||||
logLevel = logger.Error
|
||||
} else {
|
||||
logLevel = logger.Info
|
||||
if pg != nil {
|
||||
pg.Close()
|
||||
}
|
||||
|
||||
return logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logLevel,
|
||||
IgnoreRecordNotFoundError: isProduction,
|
||||
ParameterizedQueries: isProduction,
|
||||
Colorful: !isProduction,
|
||||
},
|
||||
)
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
func getGormLogger() gormLogger.Interface {
|
||||
loggerOpts := make([]slogGorm.Option, 0, 5)
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.WithSlowThreshold(200*time.Millisecond),
|
||||
slogGorm.WithErrorField("error"),
|
||||
)
|
||||
|
||||
if common.EnvConfig.LogLevel == "debug" {
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
|
||||
slogGorm.WithRecordNotFoundError(),
|
||||
slogGorm.WithTraceAll(),
|
||||
)
|
||||
|
||||
} else {
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
|
||||
slogGorm.WithIgnoreTrace(),
|
||||
)
|
||||
}
|
||||
|
||||
return slogGorm.New(loggerOpts...)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -12,9 +15,15 @@ import (
|
||||
|
||||
// When building for E2E tests, add the e2etest controller
|
||||
func init() {
|
||||
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService){
|
||||
func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService) {
|
||||
testService := service.NewTestService(db, appConfigService, jwtService)
|
||||
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
|
||||
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
controller.NewTestController(apiGroup, testService)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyFilePem = "jwt_private_key.pem"
|
||||
)
|
||||
|
||||
func migrateKey() {
|
||||
err := migrateKeyInternal(common.EnvConfig.KeysPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to perform migration of keys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateKeyInternal(basePath string) error {
|
||||
// First, check if there's already a JWK stored
|
||||
jwkPath := filepath.Join(basePath, service.PrivateKeyFile)
|
||||
ok, err := utils.FileExists(jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
if ok {
|
||||
// There's already a key as JWK, so we don't do anything else here
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's a PEM file
|
||||
pemPath := filepath.Join(basePath, privateKeyFilePem)
|
||||
ok, err = utils.FileExists(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (PEM) exists at path '%s': %w", pemPath, err)
|
||||
}
|
||||
if !ok {
|
||||
// No file to migrate, return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load and validate the key
|
||||
key, err := loadKeyPEM(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load private key file (PEM) at path '%s': %w", pemPath, err)
|
||||
}
|
||||
err = service.ValidateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key object is invalid: %w", err)
|
||||
}
|
||||
|
||||
// Save the key as JWK
|
||||
err = service.SaveKeyJWK(key, jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
// Finally, delete the PEM file
|
||||
err = os.Remove(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove migrated key at path '%s': %w", pemPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadKeyPEM(path string) (jwk.Key, error) {
|
||||
// Load the key from disk and parse it
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key data: %w", err)
|
||||
}
|
||||
|
||||
key, err := jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
// Populate the key ID using the "legacy" algorithm
|
||||
keyId, err := generateKeyID(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
||||
}
|
||||
err = key.Set(jwk.KeyIDKey, keyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set key ID: %w", err)
|
||||
}
|
||||
|
||||
// Populate other required fields
|
||||
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)
|
||||
service.EnsureAlgInKey(key)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// generateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key's PKIX-serialized structure.
|
||||
// This is used for legacy keys, imported from PEM.
|
||||
func generateKeyID(key jwk.Key) (string, error) {
|
||||
// Export the public key and serialize it to PKIX (not in a PEM block)
|
||||
// This is for backwards-compatibility with the algorithm used before the switch to JWK
|
||||
pubKey, err := key.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
var pubKeyRaw any
|
||||
err = jwk.Export(pubKey, &pubKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to export public key: %w", err)
|
||||
}
|
||||
pubASN1, err := x509.MarshalPKIXPublicKey(pubKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the public key
|
||||
hash := sha256.New()
|
||||
hash.Write(pubASN1)
|
||||
hashed := hash.Sum(nil)
|
||||
|
||||
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||
shortHash := hashed[:8]
|
||||
|
||||
// Return Base64 encoded truncated hash as Key ID
|
||||
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestMigrateKey(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("no keys exist", func(t *testing.T) {
|
||||
// Test when no keys exist
|
||||
err := migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("jwk already exists", func(t *testing.T) {
|
||||
// Create a JWK file
|
||||
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
|
||||
key, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
err = service.SaveKeyJWK(key, jwkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run migration - should do nothing
|
||||
err = migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check the file still exists
|
||||
exists, err := utils.FileExists(jwkPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Delete for next test
|
||||
err = os.Remove(jwkPath)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("migrate pem to jwk", func(t *testing.T) {
|
||||
// Create a PEM file
|
||||
pemPath := filepath.Join(tempDir, privateKeyFilePem)
|
||||
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
|
||||
|
||||
// Generate RSA key and save as PEM
|
||||
createRSAPrivateKeyPEM(t, pemPath)
|
||||
|
||||
// Run migration
|
||||
err := migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check PEM file is gone
|
||||
exists, err := utils.FileExists(pemPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
|
||||
// Check JWK file exists
|
||||
exists, err = utils.FileExists(jwkPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify the JWK can be loaded
|
||||
data, err := os.ReadFile(jwkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = jwk.ParseKey(data)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadKeyPEM(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("successfully load PEM key", func(t *testing.T) {
|
||||
pemPath := filepath.Join(tempDir, "test_key.pem")
|
||||
|
||||
// Generate RSA key and save as PEM
|
||||
createRSAPrivateKeyPEM(t, pemPath)
|
||||
|
||||
// Load the key
|
||||
key, err := loadKeyPEM(pemPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key properties
|
||||
assert.NotEmpty(t, key)
|
||||
|
||||
// Check key ID is set
|
||||
var keyID string
|
||||
err = key.Get(jwk.KeyIDKey, &keyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
// Check algorithm is set
|
||||
var alg jwa.SignatureAlgorithm
|
||||
err = key.Get(jwk.AlgorithmKey, &alg)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, alg)
|
||||
|
||||
// Check key usage is set
|
||||
var keyUsage string
|
||||
err = key.Get(jwk.KeyUsageKey, &keyUsage)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, service.KeyUsageSigning, keyUsage)
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
})
|
||||
|
||||
t.Run("invalid file content", func(t *testing.T) {
|
||||
invalidPath := filepath.Join(tempDir, "invalid.pem")
|
||||
err := os.WriteFile(invalidPath, []byte("not a valid PEM"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := loadKeyPEM(invalidPath)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateKeyID(t *testing.T) {
|
||||
key, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID, err := generateKeyID(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Key ID should be non-empty
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
// Generate another key ID to prove it depends on the key
|
||||
key2, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID2, err := generateKeyID(key2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The two key IDs should be different
|
||||
assert.NotEqual(t, keyID, keyID2)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestRSAKey() (jwk.Key, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := jwk.Import(privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// createRSAPrivateKeyPEM generates an RSA private key and returns its PEM-encoded form
|
||||
func createRSAPrivateKeyPEM(t *testing.T, pemPath string) ([]byte, *rsa.PrivateKey) {
|
||||
// Generate RSA key
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encode to PEM format
|
||||
pemData := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
|
||||
err = os.WriteFile(pemPath, pemData, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return pemData, privKey
|
||||
}
|
||||
215
backend/internal/bootstrap/observability_boostrap.go
Normal file
215
backend/internal/bootstrap/observability_boostrap.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"github.com/lmittmann/tint"
|
||||
"github.com/mattn/go-isatty"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
globallog "go.opentelemetry.io/otel/log/global"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
func defaultResource() (*resource.Resource, error) {
|
||||
return resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewSchemaless(
|
||||
semconv.ServiceName(common.Name),
|
||||
semconv.ServiceVersion(common.Version),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func initObservability(ctx context.Context) (shutdownFns []servicerunner.Service, httpClient *http.Client, err error) {
|
||||
resource, err := defaultResource()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
|
||||
}
|
||||
|
||||
shutdownFns = make([]servicerunner.Service, 0, 3)
|
||||
|
||||
httpClient = &http.Client{}
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Indicates a development-time error
|
||||
panic("Default transport is not of type *http.Transport")
|
||||
}
|
||||
httpClient.Transport = defaultTransport.Clone()
|
||||
|
||||
// Logging
|
||||
loggingShutdownFn, err := initOtelLogging(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if loggingShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, loggingShutdownFn)
|
||||
}
|
||||
|
||||
// Tracing
|
||||
tracingShutdownFn, err := initOtelTracing(ctx, resource, httpClient)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if tracingShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, tracingShutdownFn)
|
||||
}
|
||||
|
||||
// Metrics
|
||||
metricsShutdownFn, err := initOtelMetrics(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if metricsShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, metricsShutdownFn)
|
||||
}
|
||||
|
||||
return shutdownFns, httpClient, nil
|
||||
}
|
||||
|
||||
func initOtelLogging(ctx context.Context, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) {
|
||||
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
|
||||
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
|
||||
os.Setenv("OTEL_LOGS_EXPORTER", "none")
|
||||
}
|
||||
exp, err := autoexport.NewLogExporter(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
|
||||
}
|
||||
|
||||
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
|
||||
|
||||
// Create the console handler
|
||||
var handler slog.Handler
|
||||
if common.EnvConfig.LogJSON {
|
||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
} else {
|
||||
handler = tint.NewHandler(os.Stdout, &tint.Options{
|
||||
TimeFormat: time.Stamp,
|
||||
Level: level,
|
||||
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
|
||||
})
|
||||
}
|
||||
|
||||
// When log export is enabled, also send logs to OpenTelemetry through a batch processor.
|
||||
// When it's disabled (the exporter is a no-op), we skip the OTel handler entirely so we don't convert and buffer every log record just to drop it.
|
||||
if !autoexport.IsNoneLogExporter(exp) {
|
||||
// Create the logger provider
|
||||
provider := sdklog.NewLoggerProvider(
|
||||
sdklog.WithProcessor(
|
||||
sdklog.NewBatchProcessor(exp),
|
||||
),
|
||||
sdklog.WithResource(resource),
|
||||
)
|
||||
|
||||
// Set the logger provider globally
|
||||
globallog.SetLoggerProvider(provider)
|
||||
|
||||
handler = slog.NewMultiHandler(
|
||||
handler,
|
||||
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
|
||||
)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
lpCtx, lpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer lpCancel()
|
||||
shutdownErr := provider.Shutdown(lpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down logs exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Set the default slog to send logs to OTel and add the app name
|
||||
log := slog.New(handler).
|
||||
With(slog.String("app", common.Name)).
|
||||
With(slog.String("version", common.Version))
|
||||
slog.SetDefault(log)
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
|
||||
func initOtelTracing(ctx context.Context, resource *resource.Resource, httpClient *http.Client) (shutdownFn servicerunner.Service, err error) {
|
||||
// If the env var OTEL_TRACES_EXPORTER is empty, we set it to "none"
|
||||
if os.Getenv("OTEL_TRACES_EXPORTER") == "" {
|
||||
os.Setenv("OTEL_TRACES_EXPORTER", "none")
|
||||
}
|
||||
|
||||
tr, err := autoexport.NewSpanExporter(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithResource(resource),
|
||||
sdktrace.WithBatcher(tr),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
),
|
||||
)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer tpCancel()
|
||||
shutdownErr := tp.Shutdown(tpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add tracing to the HTTP client
|
||||
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
|
||||
func initOtelMetrics(ctx context.Context, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) {
|
||||
// If the env var OTEL_METRICS_EXPORTER is empty, we set it to "none"
|
||||
if os.Getenv("OTEL_METRICS_EXPORTER") == "" {
|
||||
os.Setenv("OTEL_METRICS_EXPORTER", "none")
|
||||
}
|
||||
|
||||
mr, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
|
||||
}
|
||||
|
||||
mp := metric.NewMeterProvider(
|
||||
metric.WithResource(resource),
|
||||
metric.WithReader(mr),
|
||||
)
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer mpCancel()
|
||||
shutdownErr := mp.Shutdown(mpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
@@ -1,108 +1,482 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/italypaleale/francis/builtin/ratelimit"
|
||||
"github.com/italypaleale/go-kit/servicerunner"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/tracing"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// This is used to register additional controllers for tests
|
||||
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, appConfigService *service.AppConfigService, jwtService *service.JwtService)
|
||||
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
|
||||
|
||||
// @title Pocket ID API
|
||||
// @version 1
|
||||
// @description API for Pocket ID
|
||||
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
||||
func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) {
|
||||
r, err := initEngine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = registerRoutes(r, db, svc, rateLimitServices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
serverConfig, err := initServer(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runFn := func(ctx context.Context) error {
|
||||
return runServer(ctx, serverConfig)
|
||||
}
|
||||
|
||||
return runFn, nil
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
addr string
|
||||
certProvider *tlsCertProvider
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func initEngine() (*gin.Engine, error) {
|
||||
setGinMode()
|
||||
|
||||
r := gin.New()
|
||||
initLogger(r)
|
||||
err := configureEngine(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerGlobalMiddleware(r)
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func setGinMode() {
|
||||
// Set the appropriate Gin mode based on the environment
|
||||
switch common.EnvConfig.AppEnv {
|
||||
case "production":
|
||||
case common.AppEnvProduction:
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
case "development":
|
||||
case common.AppEnvDevelopment:
|
||||
gin.SetMode(gin.DebugMode)
|
||||
case "test":
|
||||
case common.AppEnvTest:
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(gin.Logger())
|
||||
|
||||
// Initialize services
|
||||
emailService, err := service.NewEmailService(appConfigService, db)
|
||||
func configureEngine(r *gin.Engine) error {
|
||||
err := r.SetTrustedProxies(common.EnvConfig.TrustProxy)
|
||||
if err != nil {
|
||||
log.Fatalf("Unable to create email service: %s", err)
|
||||
return fmt.Errorf("failed to configure trusted proxies: %w", err)
|
||||
}
|
||||
|
||||
geoLiteService := service.NewGeoLiteService()
|
||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
||||
jwtService := service.NewJwtService(appConfigService)
|
||||
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
|
||||
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
|
||||
customClaimService := service.NewCustomClaimService(db)
|
||||
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
|
||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
||||
apiKeyService := service.NewApiKeyService(db)
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
||||
|
||||
// Setup global middleware
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
|
||||
|
||||
job.RegisterLdapJobs(ldapService, appConfigService)
|
||||
job.RegisterDbCleanupJobs(db)
|
||||
|
||||
// Initialize middleware for specific routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
|
||||
// Set up API routes
|
||||
apiGroup := r.Group("/api")
|
||||
controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService)
|
||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService)
|
||||
controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
for _, f := range registerTestControllers {
|
||||
f(apiGroup, db, appConfigService, jwtService)
|
||||
}
|
||||
if common.EnvConfig.TrustedPlatform != "" {
|
||||
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
|
||||
}
|
||||
|
||||
// Set up base routes
|
||||
baseGroup := r.Group("/")
|
||||
controller.NewWellKnownController(baseGroup, jwtService)
|
||||
r.Use(otelgin.Middleware(
|
||||
common.Name,
|
||||
otelgin.WithFilter(shouldTraceRequest)),
|
||||
)
|
||||
|
||||
// Get the listener
|
||||
l, err := net.Listen("tcp", common.EnvConfig.Host+":"+common.EnvConfig.Port)
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Notify systemd that we are ready
|
||||
if err := systemd.SdNotifyReady(); err != nil {
|
||||
log.Println("Unable to notify systemd that the service is ready: ", err)
|
||||
// continue to serve anyway since it's not that important
|
||||
}
|
||||
|
||||
// Serve requests
|
||||
if err := r.RunListener(l); err != nil {
|
||||
log.Fatal(err)
|
||||
// shouldTraceRequest reports whether an incoming request should be traced.
|
||||
// It traces only requests handled by real backend routes (the API, the OIDC/OAuth endpoints, and the well-known documents).
|
||||
// Everything else falls through to the frontend NoRoute handler, which serves the SPA shell and static assets; tracing those would produce noisy, unparented spans named just "GET" with an empty http.route.
|
||||
func shouldTraceRequest(r *http.Request) bool {
|
||||
p := r.URL.Path
|
||||
switch {
|
||||
case strings.HasPrefix(p, "/api/"),
|
||||
strings.HasPrefix(p, "/.well-known/"),
|
||||
p == "/authorize":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func registerGlobalMiddleware(r *gin.Engine) {
|
||||
r.Use(middleware.HeadMiddleware())
|
||||
r.Use(middleware.NewCacheControlMiddleware().Add())
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewCspMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
}
|
||||
|
||||
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) error {
|
||||
|
||||
err := frontend.RegisterFrontend(r)
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to register frontend: %w", err)
|
||||
}
|
||||
|
||||
// Initialize middleware for specific routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices)
|
||||
apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI)
|
||||
|
||||
apiGroup := r.Group("/api", apiRateLimitMiddleware)
|
||||
baseGroup := r.Group("/", apiRateLimitMiddleware)
|
||||
|
||||
svc.apiKeyModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.WithAdminNotRequired().Add(),
|
||||
authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(),
|
||||
)
|
||||
svc.webauthnModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.WithAdminNotRequired().Add(),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate),
|
||||
)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
svc.apiModule.RegisterRoutes(apiGroup, authMiddleware.Add())
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
svc.userSignUpModule.RegisterRoutes(apiGroup,
|
||||
authMiddleware.Add(),
|
||||
rateLimitMiddleware.Add(middleware.RateLimitSignup),
|
||||
)
|
||||
|
||||
optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add()
|
||||
browserAuth := authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add()
|
||||
svc.oidcModule.RegisterRoutes(baseGroup, apiGroup, optionalBrowserAuth, browserAuth)
|
||||
|
||||
registerTestRoutes(apiGroup, db, svc)
|
||||
|
||||
controller.NewWellKnownController(baseGroup, svc.jwtService)
|
||||
|
||||
// These are not rate-limited.
|
||||
controller.NewHealthzController(r)
|
||||
|
||||
// Receives OTLP trace payloads from the browser SPA (POST /internal/telemetry/traces) and forwards them to the collector, when trace export is enabled.
|
||||
// Outside /api, so it's unauthenticated and not traced, but it is rate-limited.
|
||||
tracing.NewTelemetryController(r, rateLimitMiddleware.Add(middleware.RateLimitInternal))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
if common.EnvConfig.AppEnv.IsProduction() {
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range registerTestControllers {
|
||||
f(apiGroup, db, svc)
|
||||
}
|
||||
}
|
||||
|
||||
func initServer(r *gin.Engine) (*serverConfig, error) {
|
||||
protocols, tlsConfig, certProvider, err := initServerProtocols()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var socketFn func() (*socket, error)
|
||||
switch {
|
||||
case common.EnvConfig.SystemdSocket:
|
||||
socketFn = systemdSocket
|
||||
case common.EnvConfig.UnixSocket != "":
|
||||
socketFn = unixSocket
|
||||
default:
|
||||
socketFn = tcpSocket
|
||||
}
|
||||
|
||||
socket, err := socketFn()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addr := socket.addr
|
||||
listener := socket.listener
|
||||
server := newHTTPServer(r, protocols)
|
||||
|
||||
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
|
||||
}
|
||||
|
||||
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(true)
|
||||
|
||||
if common.EnvConfig.TLSCertFile == "" || common.EnvConfig.TLSKeyFile == "" {
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
return protocols, nil, nil, nil
|
||||
}
|
||||
|
||||
protocols.SetHTTP2(true)
|
||||
certProvider, err := newCertProvider(common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to load TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
GetCertificate: certProvider.GetCertificate,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
NextProtos: []string{"h2"},
|
||||
}
|
||||
|
||||
slog.Info("TLS enabled")
|
||||
return protocols, tlsConfig, certProvider, nil
|
||||
}
|
||||
|
||||
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
|
||||
return &http.Server{
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Protocols: protocols,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
// HEAD requests don't get matched by Gin routes, so we convert them to GET
|
||||
// middleware.HeadMiddleware will convert them back to HEAD later
|
||||
if req.Method == http.MethodHead {
|
||||
req.Method = http.MethodGet
|
||||
ctx := context.WithValue(req.Context(), middleware.IsHeadRequestCtxKey{}, true)
|
||||
req = req.WithContext(ctx)
|
||||
}
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func runServer(ctx context.Context, config *serverConfig) error {
|
||||
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
|
||||
|
||||
certWatcher, err := startCertWatcher(ctx, config.certProvider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeCertWatcher(certWatcher)
|
||||
|
||||
startHTTPServer(config)
|
||||
notifySystemdReady()
|
||||
|
||||
<-ctx.Done()
|
||||
|
||||
// We do not pass the context because it's already been canceled
|
||||
//nolint:contextcheck
|
||||
return shutdownServer(config.server)
|
||||
}
|
||||
|
||||
func startCertWatcher(ctx context.Context, certProvider *tlsCertProvider) (*fsnotify.Watcher, error) {
|
||||
if certProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
certWatcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate watcher: %w", err)
|
||||
}
|
||||
|
||||
if err := certWatcher.Add(common.EnvConfig.TLSCertFile); err != nil {
|
||||
certWatcher.Close()
|
||||
return nil, fmt.Errorf("failed to watch TLS certificate: %w", err)
|
||||
}
|
||||
if err := certWatcher.Add(common.EnvConfig.TLSKeyFile); err != nil {
|
||||
certWatcher.Close()
|
||||
return nil, fmt.Errorf("failed to watch TLS key: %w", err)
|
||||
}
|
||||
|
||||
go certProvider.StartWatching(ctx, certWatcher)
|
||||
return certWatcher, nil
|
||||
}
|
||||
|
||||
func closeCertWatcher(certWatcher *fsnotify.Watcher) {
|
||||
if certWatcher != nil {
|
||||
certWatcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func startHTTPServer(config *serverConfig) {
|
||||
go func() {
|
||||
defer config.listener.Close()
|
||||
|
||||
listener := config.listener
|
||||
if config.tlsConfig != nil {
|
||||
listener = tls.NewListener(config.listener, config.tlsConfig)
|
||||
}
|
||||
srvErr := config.server.Serve(listener)
|
||||
|
||||
if srvErr != http.ErrServerClosed {
|
||||
slog.Error("Error starting app server", "error", srvErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func notifySystemdReady() {
|
||||
err := systemd.SdNotifyReady()
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownServer(srv *http.Server) error {
|
||||
// Note we use the background context here as ctx has been canceled already
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
|
||||
shutdownCancel()
|
||||
if shutdownErr != nil {
|
||||
// Log the error only (could be context canceled)
|
||||
slog.Warn("App server shutdown error", "error", shutdownErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initLogger(r *gin.Engine) {
|
||||
loggerSkipPathsPrefix := []string{
|
||||
"GET /api/application-images/logo",
|
||||
"GET /api/application-images/background",
|
||||
"GET /api/application-images/favicon",
|
||||
"GET /api/application-images/email",
|
||||
"GET /_app",
|
||||
"GET /fonts",
|
||||
"GET /healthz",
|
||||
"HEAD /healthz",
|
||||
}
|
||||
|
||||
r.Use(sloggin.SetLogger(
|
||||
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
|
||||
return slog.Default()
|
||||
}),
|
||||
sloggin.WithSkipper(func(c *gin.Context) bool {
|
||||
for _, prefix := range loggerSkipPathsPrefix {
|
||||
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// tlsCertProvider holds certificates that can be dynamically reloaded
|
||||
type tlsCertProvider struct {
|
||||
certMutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
certFile string
|
||||
keyFile string
|
||||
forceReload atomic.Bool
|
||||
}
|
||||
|
||||
// GetCertificate implements tls.GetCertificate interface for dynamic certificate loading
|
||||
func (p *tlsCertProvider) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if p.forceReload.Load() {
|
||||
p.certMutex.Lock()
|
||||
p.forceReload.Store(false)
|
||||
p.certMutex.Unlock()
|
||||
}
|
||||
|
||||
p.certMutex.RLock()
|
||||
defer p.certMutex.RUnlock()
|
||||
return p.cert, nil
|
||||
}
|
||||
|
||||
// newCertProvider creates a new certificate provider with initial certificates loaded
|
||||
func newCertProvider(certFile, keyFile string) (*tlsCertProvider, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tlsCertProvider{
|
||||
cert: &cert,
|
||||
certFile: certFile,
|
||||
keyFile: keyFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reloadCertificate reloads the certificate from disk
|
||||
func (p *tlsCertProvider) reloadCertificate() error {
|
||||
cert, err := tls.LoadX509KeyPair(p.certFile, p.keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
p.certMutex.Lock()
|
||||
p.cert = &cert
|
||||
p.certMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartWatching begins monitoring the certificate files for changes with debouncing
|
||||
func (p *tlsCertProvider) StartWatching(ctx context.Context, watcher *fsnotify.Watcher) {
|
||||
debounceDuration := 1 * time.Second
|
||||
reloadTimer := time.NewTimer(debounceDuration)
|
||||
reloadTimer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Only process write/rename events for certificate/key files
|
||||
if event.Has(fsnotify.Write | fsnotify.Rename) {
|
||||
// Reset the debounce timer whenever we get a relevant event
|
||||
reloadTimer.Stop()
|
||||
// Drain the channel if there's a pending value
|
||||
select {
|
||||
case <-reloadTimer.C:
|
||||
default:
|
||||
}
|
||||
reloadTimer.Reset(debounceDuration)
|
||||
slog.Debug("TLS file change detected, debouncing", slog.String("path", event.Name))
|
||||
}
|
||||
case <-reloadTimer.C:
|
||||
// Timer fired - no more events in 500ms, so reload
|
||||
slog.Info("Reloading TLS certificate")
|
||||
|
||||
if err := p.reloadCertificate(); err != nil {
|
||||
slog.Error("Failed to reload TLS certificate", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
p.forceReload.Store(true)
|
||||
slog.Info("TLS certificate reloaded successfully")
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slog.Error("Certificate watcher error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
35
backend/internal/bootstrap/scheduler_bootstrap.go
Normal file
35
backend/internal/bootstrap/scheduler_bootstrap.go
Normal file
@@ -0,0 +1,35 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
)
|
||||
|
||||
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
|
||||
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
|
||||
}
|
||||
err = scheduler.RegisterGeoLiteUpdateJobs(ctx, svc.geoLiteService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register GeoLite DB update service: %w", err)
|
||||
}
|
||||
err = scheduler.RegisterDbCleanupJobs(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err)
|
||||
}
|
||||
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
|
||||
}
|
||||
err = scheduler.RegisterScimJobs(ctx, svc.scimService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register SCIM scheduler job: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
147
backend/internal/bootstrap/services_bootstrap.go
Normal file
147
backend/internal/bootstrap/services_bootstrap.go
Normal file
@@ -0,0 +1,147 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/italypaleale/francis/host/local"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/api"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/apikey"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/appconfig"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/oidc"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/usersignup"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/webauthn"
|
||||
)
|
||||
|
||||
type services struct {
|
||||
appConfigService *appconfig.AppConfigService
|
||||
appImagesService *service.AppImagesService
|
||||
emailService *service.EmailService
|
||||
geoLiteService *service.GeoLiteService
|
||||
auditLogService *service.AuditLogService
|
||||
jwtService *service.JwtService
|
||||
scimService *service.ScimService
|
||||
userService *service.UserService
|
||||
customClaimService *service.CustomClaimService
|
||||
oidcService *service.OidcService
|
||||
userGroupService *service.UserGroupService
|
||||
ldapService *service.LdapService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
|
||||
apiKeyModule *apikey.Module
|
||||
oidcModule *oidc.Module
|
||||
webauthnModule *webauthn.Module
|
||||
userSignUpModule *usersignup.Module
|
||||
apiModule *api.Module
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
func initServices(
|
||||
ctx context.Context,
|
||||
db *gorm.DB,
|
||||
instanceID string,
|
||||
actors *local.Host,
|
||||
httpClient *http.Client,
|
||||
imageExtensions map[string]string,
|
||||
fileStorage storage.FileStorage,
|
||||
scheduler *job.Scheduler,
|
||||
) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
|
||||
// Init the app config service
|
||||
svc.appConfigService, err = appconfig.NewService(ctx, actors, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||
}
|
||||
|
||||
svc.fileStorage = fileStorage
|
||||
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
|
||||
svc.appLockService = service.NewAppLockService(db)
|
||||
|
||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create email service: %w", err)
|
||||
}
|
||||
|
||||
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
||||
svc.jwtService, err = service.NewJwtService(ctx, db, instanceID, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||
}
|
||||
|
||||
svc.customClaimService = service.NewCustomClaimService(db)
|
||||
svc.webauthnModule, err = webauthn.New(webauthn.Dependencies{
|
||||
DB: db,
|
||||
AppURL: common.EnvConfig.AppURL,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
AppConfig: svc.appConfigService,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WebAuthn module: %w", err)
|
||||
}
|
||||
|
||||
svc.scimService = service.NewScimService(db, scheduler, httpClient)
|
||||
|
||||
svc.apiModule = api.New(api.Dependencies{DB: db, Issuer: common.EnvConfig.AppURL})
|
||||
|
||||
svc.oidcModule, err = oidc.New(ctx, oidc.Dependencies{
|
||||
DB: db,
|
||||
HTTPClient: httpClient,
|
||||
Config: oidc.Config{
|
||||
BaseURL: common.EnvConfig.AppURL,
|
||||
TokenBaseURL: common.EnvConfig.AppURL,
|
||||
Secret: common.EnvConfig.EncryptionKey,
|
||||
AllowInsecureCallbackURLs: common.EnvConfig.AllowInsecureCallbackURLs,
|
||||
},
|
||||
Signer: svc.jwtService,
|
||||
CustomClaims: svc.customClaimService,
|
||||
Reauth: svc.webauthnModule,
|
||||
AuditLog: svc.auditLogService,
|
||||
APIAccess: svc.apiModule,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC module: %w", err)
|
||||
}
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.oidcModule.Preview, svc.scimService, httpClient, fileStorage)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||
}
|
||||
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
|
||||
|
||||
svc.apiKeyModule, err = apikey.New(ctx, apikey.Dependencies{
|
||||
DB: db,
|
||||
StaticApiKey: common.EnvConfig.StaticApiKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create API key module: %w", err)
|
||||
}
|
||||
|
||||
svc.userSignUpModule = usersignup.New(usersignup.Dependencies{
|
||||
DB: db,
|
||||
Signer: svc.jwtService,
|
||||
AuditLog: svc.auditLogService,
|
||||
AppConfig: svc.appConfigService,
|
||||
UserCreator: svc.userService,
|
||||
})
|
||||
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||
|
||||
svc.versionService = service.NewVersionService(httpClient)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
51
backend/internal/bootstrap/socket.go
Normal file
51
backend/internal/bootstrap/socket.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type socket struct {
|
||||
addr string
|
||||
listener net.Listener
|
||||
}
|
||||
|
||||
func unixSocket() (*socket, error) {
|
||||
addr := common.EnvConfig.UnixSocket
|
||||
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
|
||||
|
||||
listener, err := net.Listen("unix", addr) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create UNIX socket: %w", err)
|
||||
}
|
||||
|
||||
if common.EnvConfig.UnixSocketMode != "" {
|
||||
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||
if err != nil {
|
||||
listener.Close()
|
||||
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
|
||||
listener.Close()
|
||||
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
}
|
||||
|
||||
return &socket{addr, listener}, nil
|
||||
}
|
||||
|
||||
func tcpSocket() (*socket, error) {
|
||||
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
|
||||
|
||||
listener, err := net.Listen("tcp", addr) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create TCP socket: %w", err)
|
||||
}
|
||||
|
||||
return &socket{addr, listener}, nil
|
||||
}
|
||||
27
backend/internal/bootstrap/systemd_socket_linux.go
Normal file
27
backend/internal/bootstrap/systemd_socket_linux.go
Normal file
@@ -0,0 +1,27 @@
|
||||
//go:build linux
|
||||
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/coreos/go-systemd/activation"
|
||||
)
|
||||
|
||||
func systemdSocket() (*socket, error) {
|
||||
listeners, err := activation.Listeners()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to receive socket from systemd: %w", err)
|
||||
}
|
||||
|
||||
if len(listeners) == 0 {
|
||||
return nil, errors.New("did not receive any sockets from systemd")
|
||||
}
|
||||
|
||||
if len(listeners) > 1 {
|
||||
return nil, errors.New("received too many sockets from systemd")
|
||||
}
|
||||
|
||||
return &socket{"(systemd)", listeners[0]}, nil
|
||||
}
|
||||
9
backend/internal/bootstrap/systemd_socket_nonlinux.go
Normal file
9
backend/internal/bootstrap/systemd_socket_nonlinux.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build !linux
|
||||
|
||||
package bootstrap
|
||||
|
||||
import "errors"
|
||||
|
||||
func systemdSocket() (*socket, error) {
|
||||
return nil, errors.New("systemd socket activation is only supported on Linux")
|
||||
}
|
||||
186
backend/internal/cmds/encryption_key_rotate.go
Normal file
186
backend/internal/cmds/encryption_key_rotate.go
Normal file
@@ -0,0 +1,186 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
)
|
||||
|
||||
type encryptionKeyRotateFlags struct {
|
||||
NewKey string
|
||||
Yes bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags encryptionKeyRotateFlags
|
||||
|
||||
encryptionKeyRotateCmd := &cobra.Command{
|
||||
Use: "encryption-key-rotate",
|
||||
Short: "Re-encrypts data using a new encryption key",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db, _, err := bootstrap.NewDatabase(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
instanceID, err := instanceid.Load(cmd.Context(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return encryptionKeyRotate(cmd.Context(), flags, db, instanceID, &common.EnvConfig)
|
||||
},
|
||||
}
|
||||
|
||||
encryptionKeyRotateCmd.Flags().StringVar(&flags.NewKey, "new-key", "", "New encryption key to re-encrypt data with")
|
||||
encryptionKeyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
|
||||
|
||||
rootCmd.AddCommand(encryptionKeyRotateCmd)
|
||||
}
|
||||
|
||||
func encryptionKeyRotate(ctx context.Context, flags encryptionKeyRotateFlags, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) error {
|
||||
oldKey := envConfig.EncryptionKey
|
||||
newKey := []byte(flags.NewKey)
|
||||
if len(newKey) == 0 {
|
||||
return errors.New("new encryption key is required (--new-key)")
|
||||
}
|
||||
if len(newKey) < 16 {
|
||||
return errors.New("new encryption key must be at least 16 bytes long")
|
||||
}
|
||||
|
||||
if !flags.Yes {
|
||||
fmt.Println("WARNING: Rotating the encryption key will re-encrypt secrets in the database. Pocket-ID must be restarted with the new ENCRYPTION_KEY after rotation is complete.")
|
||||
ok, err := utils.PromptForConfirmation("Continue")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive the encryption keys used for the JWK encryption
|
||||
oldKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: oldKey}, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive old key encryption key: %w", err)
|
||||
}
|
||||
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive new key encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Derive the encryption keys used for EncryptedString fields
|
||||
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive old encrypted string key: %w", err)
|
||||
}
|
||||
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to derive new encrypted string key: %w", err)
|
||||
}
|
||||
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
err = rotateSigningKeyEncryption(ctx, tx, oldKek, newKek)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = rotateScimTokens(tx, oldEncKey, newEncKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("Encryption key rotation completed successfully.")
|
||||
fmt.Println("Restart pocket-id with the new ENCRYPTION_KEY to use the rotated data.")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func rotateSigningKeyEncryption(ctx context.Context, db *gorm.DB, oldKek []byte, newKek []byte) error {
|
||||
oldProvider := &jwkutils.KeyProviderDatabase{}
|
||||
err := oldProvider.Init(jwkutils.KeyProviderOpts{
|
||||
DB: db,
|
||||
Kek: oldKek,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to init key provider with old encryption key: %w", err)
|
||||
}
|
||||
|
||||
key, err := oldProvider.LoadKey(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load signing key using old encryption key: %w", err)
|
||||
}
|
||||
if key == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
newProvider := &jwkutils.KeyProviderDatabase{}
|
||||
err = newProvider.Init(jwkutils.KeyProviderOpts{
|
||||
DB: db,
|
||||
Kek: newKek,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to init key provider with new encryption key: %w", err)
|
||||
}
|
||||
|
||||
if err := newProvider.SaveKey(ctx, key); err != nil {
|
||||
return fmt.Errorf("failed to store signing key with new encryption key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type scimTokenRow struct {
|
||||
ID string
|
||||
Token string
|
||||
}
|
||||
|
||||
func rotateScimTokens(db *gorm.DB, oldEncKey []byte, newEncKey []byte) error {
|
||||
var rows []scimTokenRow
|
||||
err := db.Model(&model.ScimServiceProvider{}).Select("id, token").Scan(&rows).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list SCIM service providers: %w", err)
|
||||
}
|
||||
|
||||
for _, row := range rows {
|
||||
if row.Token == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
decBytes, err := datatype.DecryptEncryptedStringWithKey(oldEncKey, row.Token)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decrypt SCIM token for provider %s: %w", row.ID, err)
|
||||
}
|
||||
|
||||
encValue, err := datatype.EncryptEncryptedStringWithKey(newEncKey, decBytes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt SCIM token for provider %s: %w", row.ID, err)
|
||||
}
|
||||
|
||||
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", row.ID).Update("token", encValue).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update SCIM token for provider %s: %w", row.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
89
backend/internal/cmds/encryption_key_rotate_test.go
Normal file
89
backend/internal/cmds/encryption_key_rotate_test.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestEncryptionKeyRotate(t *testing.T) {
|
||||
oldKey := []byte("old-encryption-key-123456")
|
||||
newKey := []byte("new-encryption-key-654321")
|
||||
|
||||
envConfig := &common.EnvConfigSchema{
|
||||
EncryptionKey: oldKey,
|
||||
}
|
||||
|
||||
db := testingutils.NewDatabaseForTest(t)
|
||||
|
||||
instanceID, err := instanceid.Load(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
oldKek, err := jwkutils.LoadKeyEncryptionKey(envConfig, instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
oldProvider := &jwkutils.KeyProviderDatabase{}
|
||||
require.NoError(t, oldProvider.Init(jwkutils.KeyProviderOpts{
|
||||
DB: db,
|
||||
Kek: oldKek,
|
||||
}))
|
||||
|
||||
signingKey, err := jwkutils.GenerateKey("RS256", "")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, oldProvider.SaveKey(t.Context(), signingKey))
|
||||
|
||||
oldEncKey, err := datatype.DeriveEncryptedStringKey(oldKey)
|
||||
require.NoError(t, err)
|
||||
encToken, err := datatype.EncryptEncryptedStringWithKey(oldEncKey, []byte("scim-token-123"))
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Exec(
|
||||
`INSERT INTO scim_service_providers (id, created_at, endpoint, token, oidc_client_id) VALUES (?, ?, ?, ?, ?)`,
|
||||
"scim-1",
|
||||
time.Now(),
|
||||
"https://example.com/scim",
|
||||
encToken,
|
||||
"client-1",
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
flags := encryptionKeyRotateFlags{
|
||||
NewKey: string(newKey),
|
||||
Yes: true,
|
||||
}
|
||||
err = encryptionKeyRotate(t.Context(), flags, db, instanceID, envConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
newKek, err := jwkutils.LoadKeyEncryptionKey(&common.EnvConfigSchema{EncryptionKey: newKey}, instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
newProvider := &jwkutils.KeyProviderDatabase{}
|
||||
require.NoError(t, newProvider.Init(jwkutils.KeyProviderOpts{
|
||||
DB: db,
|
||||
Kek: newKek,
|
||||
}))
|
||||
|
||||
rotatedKey, err := newProvider.LoadKey(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, rotatedKey)
|
||||
|
||||
var storedToken string
|
||||
err = db.Model(&model.ScimServiceProvider{}).Where("id = ?", "scim-1").Pluck("token", &storedToken).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
newEncKey, err := datatype.DeriveEncryptedStringKey(newKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
decBytes, err := datatype.DecryptEncryptedStringWithKey(newEncKey, storedToken)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "scim-token-123", string(decBytes))
|
||||
}
|
||||
70
backend/internal/cmds/export.go
Normal file
70
backend/internal/cmds/export.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type exportFlags struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags exportFlags
|
||||
|
||||
exportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Exports all data of Pocket ID into a ZIP file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runExport(cmd.Context(), flags)
|
||||
},
|
||||
}
|
||||
|
||||
exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout")
|
||||
|
||||
rootCmd.AddCommand(exportCmd)
|
||||
}
|
||||
|
||||
// runExport orchestrates the export flow
|
||||
func runExport(ctx context.Context, flags exportFlags) error {
|
||||
db, _, err := bootstrap.NewDatabase(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
storage, err := bootstrap.InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
|
||||
exportService := service.NewExportService(db, storage)
|
||||
|
||||
var w io.Writer
|
||||
if flags.Path == "-" {
|
||||
w = os.Stdout
|
||||
} else {
|
||||
file, err := os.Create(flags.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create export file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w = file
|
||||
}
|
||||
|
||||
if err := exportService.ExportToZip(ctx, w); err != nil {
|
||||
return fmt.Errorf("failed to export data: %w", err)
|
||||
}
|
||||
|
||||
if flags.Path != "-" {
|
||||
fmt.Printf("Exported data to %s\n", flags.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
106
backend/internal/cmds/healthcheck.go
Normal file
106
backend/internal/cmds/healthcheck.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type healthcheckFlags struct {
|
||||
Endpoint string
|
||||
UnixSocket string
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
type healthcheckResult struct {
|
||||
StatusCode int
|
||||
URL string
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags healthcheckFlags
|
||||
|
||||
healthcheckCmd := &cobra.Command{
|
||||
Use: "healthcheck",
|
||||
Short: "Performs a healthcheck of a running Pocket ID instance",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
start := time.Now()
|
||||
|
||||
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if flags.UnixSocket == "" && !cmd.Flags().Changed("endpoint") {
|
||||
flags.UnixSocket = common.EnvConfig.UnixSocket
|
||||
}
|
||||
|
||||
result, err := healthcheck(ctx, flags)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx,
|
||||
"Healthcheck failed",
|
||||
"error", err,
|
||||
"ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if flags.Verbose {
|
||||
slog.InfoContext(ctx,
|
||||
"Healthcheck succeeded",
|
||||
"status", result.StatusCode,
|
||||
"url", result.URL,
|
||||
"unixSocket", flags.UnixSocket,
|
||||
"ms", time.Since(start).Milliseconds(),
|
||||
)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
|
||||
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
|
||||
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
|
||||
|
||||
rootCmd.AddCommand(healthcheckCmd)
|
||||
}
|
||||
|
||||
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
|
||||
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
|
||||
}
|
||||
|
||||
client := http.DefaultClient
|
||||
if flags.UnixSocket != "" {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
|
||||
}
|
||||
client = &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
|
||||
}
|
||||
defer res.Body.Close()
|
||||
|
||||
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("unexpected status %d from %q", res.StatusCode, url)
|
||||
}
|
||||
|
||||
return &healthcheckResult{
|
||||
StatusCode: res.StatusCode,
|
||||
URL: url,
|
||||
}, nil
|
||||
}
|
||||
79
backend/internal/cmds/healthcheck_test.go
Normal file
79
backend/internal/cmds/healthcheck_test.go
Normal file
@@ -0,0 +1,79 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestHealthcheckTCPSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/healthz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
require.Equal(t, server.URL+"/healthz", result.URL)
|
||||
}
|
||||
|
||||
func TestHealthcheckFailsOnUnexpectedStatus(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "unexpected status 500")
|
||||
}
|
||||
|
||||
func TestHealthcheckUnixSocket(t *testing.T) {
|
||||
socketPath := filepath.Join(t.TempDir(), "pocket-id.sock")
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/healthz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.Serve(listener)
|
||||
}()
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||
defer cancel()
|
||||
require.NoError(t, server.Shutdown(ctx))
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
})
|
||||
|
||||
result, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: "http://localhost:1411",
|
||||
UnixSocket: socketPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
require.Equal(t, "http://localhost:1411/healthz", result.URL)
|
||||
}
|
||||
190
backend/internal/cmds/import.go
Normal file
190
backend/internal/cmds/import.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type importFlags struct {
|
||||
Path string
|
||||
Yes bool
|
||||
ForcefullyAcquireLock bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags importFlags
|
||||
|
||||
importCmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Imports all data of Pocket ID from a ZIP file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runImport(cmd.Context(), flags)
|
||||
},
|
||||
}
|
||||
|
||||
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
|
||||
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
|
||||
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance")
|
||||
|
||||
rootCmd.AddCommand(importCmd)
|
||||
}
|
||||
|
||||
// runImport handles the high-level orchestration of the import process
|
||||
func runImport(ctx context.Context, flags importFlags) error {
|
||||
if !flags.Yes {
|
||||
ok, err := askForConfirmation()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get confirmation: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
zipReader *zip.ReadCloser
|
||||
cleanup func()
|
||||
err error
|
||||
)
|
||||
|
||||
if flags.Path == "-" {
|
||||
zipReader, cleanup, err = readZipFromStdin()
|
||||
defer cleanup()
|
||||
} else {
|
||||
zipReader, err = zip.OpenReader(flags.Path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
db, _, err := bootstrap.ConnectDatabase(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage, err := bootstrap.InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
|
||||
importService := service.NewImportService(db, storage)
|
||||
err = importService.ImportFromZip(ctx, &zipReader.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to import data from zip: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Import completed successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
|
||||
// Check if the kv table exists, in case we are starting from an empty database
|
||||
exists, err := utils.DBTableExists(db, "kv")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if kv table exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
// This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks
|
||||
// In either case, there's no lock to acquire
|
||||
fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note that we do not call a deferred Release if the data was imported
|
||||
// This is because we are overriding the contents of the database, so the lock is automatically lost
|
||||
appLockService := service.NewAppLockService(db)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
waitUntil, err := appLockService.Acquire(opCtx, force)
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
//nolint:staticcheck
|
||||
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Until(waitUntil)):
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func askForConfirmation() (bool, error) {
|
||||
fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.")
|
||||
fmt.Println()
|
||||
fmt.Println("WARNING: Import will erase all existing data at the following locations:")
|
||||
fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString))
|
||||
fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath))
|
||||
|
||||
ok, err := utils.PromptForConfirmation("Do you want to continue?")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails
|
||||
func absolutePathOrOriginal(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func readZipFromStdin() (*zip.ReadCloser, func(), error) {
|
||||
tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, os.Stdin); err != nil {
|
||||
tmpFile.Close()
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("failed to close temporary file: %w", err)
|
||||
}
|
||||
|
||||
r, err := zip.OpenReader(tmpFile.Name())
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return r, cleanup, nil
|
||||
}
|
||||
113
backend/internal/cmds/key_rotate.go
Normal file
113
backend/internal/cmds/key_rotate.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
)
|
||||
|
||||
type keyRotateFlags struct {
|
||||
Alg string
|
||||
Crv string
|
||||
Yes bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags keyRotateFlags
|
||||
|
||||
keyRotateCmd := &cobra.Command{
|
||||
Use: "key-rotate",
|
||||
Short: "Generates a new token signing key and replaces the current one",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db, _, err := bootstrap.NewDatabase(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
instanceID, err := instanceid.Load(cmd.Context(), db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return keyRotate(cmd.Context(), flags, db, instanceID, &common.EnvConfig)
|
||||
},
|
||||
}
|
||||
|
||||
keyRotateCmd.Flags().StringVarP(&flags.Alg, "alg", "a", "RS256", "Key algorithm. Supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
|
||||
keyRotateCmd.Flags().StringVarP(&flags.Crv, "crv", "c", "", "Curve name when using EdDSA keys. Supported values: Ed25519")
|
||||
keyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
|
||||
|
||||
rootCmd.AddCommand(keyRotateCmd)
|
||||
}
|
||||
|
||||
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, instanceID string, envConfig *common.EnvConfigSchema) error {
|
||||
// Validate the flags
|
||||
switch strings.ToUpper(flags.Alg) {
|
||||
case jwa.RS256().String(), jwa.RS384().String(), jwa.RS512().String(),
|
||||
jwa.ES256().String(), jwa.ES384().String(), jwa.ES512().String():
|
||||
// All good, but uppercase it for consistency
|
||||
flags.Alg = strings.ToUpper(flags.Alg)
|
||||
case strings.ToUpper(jwa.EdDSA().String()):
|
||||
// Ensure Crv is set and valid
|
||||
switch strings.ToUpper(flags.Crv) {
|
||||
case strings.ToUpper(jwa.Ed25519().String()):
|
||||
// All good, but ensure consistency in casing
|
||||
flags.Crv = jwa.Ed25519().String()
|
||||
case "":
|
||||
return errors.New("a curve name is required when algorithm is EdDSA")
|
||||
default:
|
||||
return errors.New("unsupported EdDSA curve; supported values: Ed25519")
|
||||
}
|
||||
case "":
|
||||
return errors.New("key algorithm is required")
|
||||
default:
|
||||
return errors.New("unsupported key algorithm; supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
|
||||
}
|
||||
|
||||
if !flags.Yes {
|
||||
fmt.Println("WARNING: Rotating the private key will invalidate all existing tokens. Both pocket-id and all client applications will likely need to be restarted.")
|
||||
ok, err := utils.PromptForConfirmation("Confirm")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Get the key provider
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get key provider: %w", err)
|
||||
}
|
||||
|
||||
// Generate a new key
|
||||
key, err := jwkutils.GenerateKey(flags.Alg, flags.Crv)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to generate key: %w", err)
|
||||
}
|
||||
|
||||
// Save the key
|
||||
err = keyProvider.SaveKey(ctx, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to store new key: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Key rotated successfully")
|
||||
fmt.Println("Note: if pocket-id is running, you will need to restart it for the new key to be loaded")
|
||||
|
||||
return nil
|
||||
}
|
||||
149
backend/internal/cmds/key_rotate_test.go
Normal file
149
backend/internal/cmds/key_rotate_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/instanceid"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestKeyRotate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
flags keyRotateFlags
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid RS256",
|
||||
flags: keyRotateFlags{
|
||||
Alg: "RS256",
|
||||
Yes: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid EdDSA with Ed25519",
|
||||
flags: keyRotateFlags{
|
||||
Alg: "EdDSA",
|
||||
Crv: "Ed25519",
|
||||
Yes: true,
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid algorithm",
|
||||
flags: keyRotateFlags{
|
||||
Alg: "INVALID",
|
||||
Yes: true,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "unsupported key algorithm",
|
||||
},
|
||||
{
|
||||
name: "EdDSA without curve",
|
||||
flags: keyRotateFlags{
|
||||
Alg: "EdDSA",
|
||||
Yes: true,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "a curve name is required when algorithm is EdDSA",
|
||||
},
|
||||
{
|
||||
name: "empty algorithm",
|
||||
flags: keyRotateFlags{
|
||||
Alg: "",
|
||||
Yes: true,
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "key algorithm is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||
// Set up database storage config
|
||||
envConfig := &common.EnvConfigSchema{
|
||||
EncryptionKey: []byte("test-encryption-key-characters-long"),
|
||||
}
|
||||
|
||||
// Create test database
|
||||
db := testingutils.NewDatabaseForTest(t)
|
||||
|
||||
// Load the instance ID
|
||||
instanceID, err := instanceid.Load(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Get key provider
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run the key rotation
|
||||
err = keyRotate(t.Context(), flags, db, instanceID, envConfig)
|
||||
|
||||
if wantErr {
|
||||
require.Error(t, err)
|
||||
if errMsg != "" {
|
||||
require.ErrorContains(t, err, errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key was created
|
||||
key, err := keyProvider.LoadKey(t.Context())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, key)
|
||||
|
||||
// Verify the algorithm matches what we requested
|
||||
alg, _ := key.Algorithm()
|
||||
assert.NotEmpty(t, alg)
|
||||
if flags.Alg != "" {
|
||||
expectedAlg := flags.Alg
|
||||
if expectedAlg == "EdDSA" {
|
||||
// EdDSA keys should have the EdDSA algorithm
|
||||
assert.Equal(t, "EdDSA", alg.String())
|
||||
} else {
|
||||
assert.Equal(t, expectedAlg, alg.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyRotateMultipleAlgorithms(t *testing.T) {
|
||||
algorithms := []struct {
|
||||
alg string
|
||||
crv string
|
||||
}{
|
||||
{"RS256", ""},
|
||||
{"RS384", ""},
|
||||
// Skip RSA-4096 key generation test as it can take a long time
|
||||
// {"RS512", ""},
|
||||
{"ES256", ""},
|
||||
{"ES384", ""},
|
||||
{"ES512", ""},
|
||||
{"EdDSA", "Ed25519"},
|
||||
}
|
||||
|
||||
for _, algo := range algorithms {
|
||||
t.Run(algo.alg, func(t *testing.T) {
|
||||
// Test with database storage for all algorithms
|
||||
testKeyRotateWithDatabaseStorage(t, keyRotateFlags{
|
||||
Alg: algo.alg,
|
||||
Crv: algo.crv,
|
||||
Yes: true,
|
||||
}, false, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
85
backend/internal/cmds/one_time_access_token.go
Normal file
85
backend/internal/cmds/one_time_access_token.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
Use: "one-time-access-token [username or email]",
|
||||
Short: "Generates a one-time access token for the given user",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get the username or email of the user
|
||||
userArg := args[0]
|
||||
|
||||
// Connect to the database
|
||||
db, _, err := bootstrap.NewDatabase(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the access token
|
||||
var oneTimeAccessToken *model.OneTimeAccessToken
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// Load the user to retrieve the user ID
|
||||
var user model.User
|
||||
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer queryCancel()
|
||||
txErr := tx.
|
||||
WithContext(queryCtx).
|
||||
Where("username = ? OR email = ?", userArg, userArg).
|
||||
First(&user).
|
||||
Error
|
||||
switch {
|
||||
case errors.Is(txErr, gorm.ErrRecordNotFound):
|
||||
return errors.New("user not found")
|
||||
case txErr != nil:
|
||||
return fmt.Errorf("failed to query for user: %w", txErr)
|
||||
case user.ID == "":
|
||||
return errors.New("invalid user loaded: ID is empty")
|
||||
}
|
||||
|
||||
// Create a new access token that expires in 1 hour
|
||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
|
||||
if txErr != nil {
|
||||
return fmt.Errorf("failed to generate access token: %w", txErr)
|
||||
}
|
||||
|
||||
queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
defer queryCancel()
|
||||
txErr = tx.
|
||||
WithContext(queryCtx).
|
||||
Create(oneTimeAccessToken).
|
||||
Error
|
||||
if txErr != nil {
|
||||
return fmt.Errorf("failed to save access token: %w", txErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Print the result
|
||||
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
|
||||
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(oneTimeAccessTokenCmd)
|
||||
}
|
||||
37
backend/internal/cmds/root.go
Normal file
37
backend/internal/cmds/root.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/italypaleale/go-kit/signals"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "pocket-id",
|
||||
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
|
||||
Long: "By default, this command starts the pocket-id server.",
|
||||
SilenceUsage: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Start the server
|
||||
err := bootstrap.Bootstrap(cmd.Context())
|
||||
if err != nil {
|
||||
slog.Error("Failed to run pocket-id", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
func Execute() {
|
||||
// Get a context that is canceled when the application is stopping
|
||||
ctx := signals.SignalContext(context.Background())
|
||||
|
||||
err := rootCmd.ExecuteContext(ctx)
|
||||
if err != nil {
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
19
backend/internal/cmds/version.go
Normal file
19
backend/internal/cmds/version.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
Use: "version",
|
||||
Short: "Print the version number",
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
fmt.Println("pocket-id " + common.Version)
|
||||
},
|
||||
})
|
||||
}
|
||||
8
backend/internal/common/claims.go
Normal file
8
backend/internal/common/claims.go
Normal file
@@ -0,0 +1,8 @@
|
||||
package common
|
||||
|
||||
// AuthenticationMethodsClaim is the JWT claim ("amr") used to identify how the user
|
||||
// authenticated. It is shared between the session JWTs and the OIDC tokens.
|
||||
const AuthenticationMethodsClaim = "amr"
|
||||
|
||||
// TokenTypeClaim is the JWT claim ("type") used to identify the type of token.
|
||||
const TokenTypeClaim = "type"
|
||||
@@ -1,79 +1,422 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
type AppEnv string
|
||||
type DbProvider string
|
||||
type TrustProxyConfig []string
|
||||
|
||||
const (
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
|
||||
TracerName = "github.com/pocket-id/pocket-id/backend/tracing"
|
||||
// MeterName should be passed to otel.Meter when create custom metrics.
|
||||
MeterName = "github.com/pocket-id/pocket-id/backend/metrics"
|
||||
)
|
||||
|
||||
const (
|
||||
AppEnvProduction AppEnv = "production"
|
||||
AppEnvDevelopment AppEnv = "development"
|
||||
AppEnvTest AppEnv = "test"
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||
defaultSqliteConnString string = "data/pocket-id.db"
|
||||
defaultFsUploadPath string = "data/uploads"
|
||||
AppUrl string = "http://localhost:1411"
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
AppURL string `env:"PUBLIC_APP_URL"`
|
||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING"`
|
||||
SqliteDBPath string `env:"SQLITE_DB_PATH"` // Deprecated: use "DB_CONNECTION_STRING" instead
|
||||
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` // Deprecated: use "DB_CONNECTION_STRING" instead
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
KeysPath string `env:"KEYS_PATH"`
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
|
||||
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
|
||||
DbProvider DbProvider
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
TrustProxy TrustProxyConfig `env:"TRUST_PROXY"`
|
||||
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
|
||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
AllowInsecureCallbackURLs bool `env:"ALLOW_INSECURE_CALLBACK_URLS"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
|
||||
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
|
||||
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
|
||||
|
||||
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
S3Bucket string `env:"S3_BUCKET"`
|
||||
S3Region string `env:"S3_REGION"`
|
||||
S3Endpoint string `env:"S3_ENDPOINT"`
|
||||
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
|
||||
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY" options:"file"`
|
||||
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
|
||||
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
|
||||
|
||||
Port string `env:"PORT"`
|
||||
Host string `env:"HOST" options:"toLower"`
|
||||
UnixSocket string `env:"UNIX_SOCKET"`
|
||||
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||
SystemdSocket bool `env:"SYSTEMD_SOCKET"`
|
||||
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||
|
||||
TLSCertFile string `env:"TLS_CERT" options:"file"`
|
||||
TLSKeyFile string `env:"TLS_KEY" options:"file"`
|
||||
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
|
||||
ActorsPort string `env:"ACTORS_PORT"`
|
||||
ActorsHost string `env:"ACTORS_HOST" options:"toLower"`
|
||||
|
||||
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
}
|
||||
|
||||
var EnvConfig = &EnvConfigSchema{
|
||||
AppEnv: "production",
|
||||
DbProvider: "sqlite",
|
||||
DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
|
||||
SqliteDBPath: "",
|
||||
PostgresConnectionString: "",
|
||||
UploadPath: "data/uploads",
|
||||
KeysPath: "data/keys",
|
||||
AppURL: "http://localhost",
|
||||
Port: "8080",
|
||||
Host: "0.0.0.0",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
UiConfigDisabled: false,
|
||||
}
|
||||
var EnvConfig = defaultConfig()
|
||||
|
||||
func init() {
|
||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
|
||||
// Validate the environment variables
|
||||
switch EnvConfig.DbProvider {
|
||||
case DbProviderSqlite:
|
||||
if EnvConfig.DbConnectionString == "" {
|
||||
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||
}
|
||||
case DbProviderPostgres:
|
||||
if EnvConfig.DbConnectionString == "" {
|
||||
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||
}
|
||||
default:
|
||||
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||
}
|
||||
|
||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||
err := parseEnvConfig()
|
||||
if err != nil {
|
||||
log.Fatal("PUBLIC_APP_URL is not a valid URL")
|
||||
}
|
||||
if parsedAppUrl.Path != "" {
|
||||
log.Fatal("PUBLIC_APP_URL must not contain a path")
|
||||
slog.Error("Configuration error", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() EnvConfigSchema {
|
||||
return EnvConfigSchema{
|
||||
AppEnv: AppEnvProduction,
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
FileBackend: "filesystem",
|
||||
AuditLogRetentionDays: 90,
|
||||
AllowInsecureCallbackURLs: true, // TODO: Default to false in major v3
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
ActorsPort: "1414",
|
||||
ActorsHost: "0.0.0.0",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
}
|
||||
}
|
||||
|
||||
func parseEnvConfig() error {
|
||||
parsers := map[reflect.Type]env.ParserFunc{
|
||||
reflect.TypeFor[[]byte](): func(value string) (any, error) {
|
||||
return []byte(value), nil
|
||||
},
|
||||
}
|
||||
|
||||
err := env.ParseWithOptions(&EnvConfig, env.Options{
|
||||
FuncMap: parsers,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing env config: %w", err)
|
||||
}
|
||||
|
||||
err = prepareEnvConfig(&EnvConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error preparing env config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
|
||||
func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
if shouldSkipEnvValidation(os.Args) {
|
||||
return nil
|
||||
}
|
||||
|
||||
_, err := sloggin.ParseLevel(config.LogLevel)
|
||||
if err != nil {
|
||||
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||
}
|
||||
|
||||
// Check required properties
|
||||
if len(config.EncryptionKey) < 16 {
|
||||
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
|
||||
}
|
||||
|
||||
if config.SystemdSocket && config.UnixSocket != "" {
|
||||
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
|
||||
}
|
||||
|
||||
if config.AuditLogRetentionDays <= 0 {
|
||||
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
}
|
||||
|
||||
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
|
||||
return errors.New("when set, STATIC_API_KEY must be at least 16 characters long")
|
||||
}
|
||||
|
||||
// Prepare the DB config
|
||||
prepareDbConfig(config)
|
||||
|
||||
// Validate other required options
|
||||
err = validateAppURLs(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateFileBackend(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateLocalIPv6Ranges(config.LocalIPv6Ranges)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = validateTLSConfig(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func prepareDbConfig(config *EnvConfigSchema) {
|
||||
switch {
|
||||
case config.DbConnectionString == "":
|
||||
config.DbProvider = DbProviderSqlite
|
||||
config.DbConnectionString = defaultSqliteConnString
|
||||
case strings.HasPrefix(config.DbConnectionString, "postgres://") || strings.HasPrefix(config.DbConnectionString, "postgresql://"):
|
||||
config.DbProvider = DbProviderPostgres
|
||||
default:
|
||||
config.DbProvider = DbProviderSqlite
|
||||
}
|
||||
}
|
||||
|
||||
func validateAppURLs(config *EnvConfigSchema) error {
|
||||
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
||||
if config.InternalAppURL == "" {
|
||||
config.InternalAppURL = config.AppURL
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateURLWithoutPath(config.InternalAppURL, "INTERNAL_APP_URL")
|
||||
}
|
||||
|
||||
func validateURLWithoutPath(rawURL, envName string) error {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is not a valid URL", envName)
|
||||
}
|
||||
if parsedURL.Path != "" {
|
||||
return fmt.Errorf("%s must not contain a path", envName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFileBackend(config *EnvConfigSchema) error {
|
||||
switch config.FileBackend {
|
||||
case "s3", "database":
|
||||
return nil
|
||||
case "", "filesystem":
|
||||
if config.UploadPath == "" {
|
||||
config.UploadPath = defaultFsUploadPath
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
|
||||
}
|
||||
}
|
||||
|
||||
func validateLocalIPv6Ranges(localIPv6Ranges string) error {
|
||||
ranges := strings.SplitSeq(localIPv6Ranges, ",")
|
||||
for rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := validateLocalIPv6Range(rangeStr); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLocalIPv6Range(rangeStr string) error {
|
||||
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() != nil {
|
||||
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTLSConfig(config *EnvConfigSchema) error {
|
||||
switch {
|
||||
case config.TLSCertFile != "" && config.TLSKeyFile == "":
|
||||
return errors.New("TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
|
||||
case config.TLSCertFile == "" && config.TLSKeyFile != "":
|
||||
return errors.New("TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
|
||||
}
|
||||
|
||||
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
|
||||
if _, err := os.Stat(config.TLSCertFile); err != nil {
|
||||
return fmt.Errorf("TLS_CERT_FILE not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
|
||||
if _, err := os.Stat(config.TLSKeyFile); err != nil {
|
||||
return fmt.Errorf("TLS_KEY_FILE not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
func shouldSkipEnvValidation(args []string) bool {
|
||||
for _, arg := range args[1:] {
|
||||
switch arg {
|
||||
case "-h", "--help", "help", "version":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareEnvConfig processes special options for EnvConfig fields
|
||||
func prepareEnvConfig(config *EnvConfigSchema) error {
|
||||
val := reflect.ValueOf(config).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
optionsTag := fieldType.Tag.Get("options")
|
||||
options := strings.SplitSeq(optionsTag, ",")
|
||||
|
||||
for option := range options {
|
||||
switch option {
|
||||
case "toLower":
|
||||
if field.Kind() == reflect.String {
|
||||
field.SetString(strings.ToLower(field.String()))
|
||||
}
|
||||
case "file":
|
||||
err := resolveFileBasedEnvVariable(field, fieldType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
case "trimTrailingSlash":
|
||||
if field.Kind() == reflect.String {
|
||||
field.SetString(strings.TrimRight(field.String(), "/"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
|
||||
// reads the content of the file specified by that variable, and sets the corresponding field's value.
|
||||
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
|
||||
// Only process string and []byte fields
|
||||
isString := field.Kind() == reflect.String
|
||||
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
|
||||
if !isString && !isByteSlice {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only process fields with the "env" tag
|
||||
envTag := fieldType.Tag.Get("env")
|
||||
if envTag == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
envVarName := envTag
|
||||
if commaIndex := len(envTag); commaIndex > 0 {
|
||||
envVarName = envTag[:commaIndex]
|
||||
}
|
||||
|
||||
// If the file environment variable is not set, skip
|
||||
envVarFileName := envVarName + "_FILE"
|
||||
envVarFileValue := os.Getenv(envVarFileName)
|
||||
if envVarFileValue == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// #nosec G703 - Path is passed by the admin
|
||||
fileContent, err := os.ReadFile(envVarFileValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
|
||||
}
|
||||
|
||||
if isString {
|
||||
field.SetString(strings.TrimSpace(string(fileContent)))
|
||||
} else {
|
||||
field.SetBytes(fileContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a AppEnv) IsProduction() bool {
|
||||
return a == AppEnvProduction
|
||||
}
|
||||
|
||||
func (a AppEnv) IsTest() bool {
|
||||
return a == AppEnvTest
|
||||
}
|
||||
|
||||
func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
|
||||
value := strings.TrimSpace(string(text))
|
||||
|
||||
// Support boolean values for completely enabling or disabling trust proxy
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err == nil {
|
||||
if enabled {
|
||||
*config = TrustProxyConfig{"0.0.0.0/0", "::/0"}
|
||||
} else {
|
||||
*config = nil
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Normalize and validate each explicit proxy before the server starts
|
||||
proxies := strings.Split(value, ",")
|
||||
for i, proxy := range proxies {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
proxies[i] = proxy
|
||||
}
|
||||
|
||||
*config = proxies
|
||||
return nil
|
||||
}
|
||||
|
||||
357
backend/internal/common/env_config_test.go
Normal file
357
backend/internal/common/env_config_test.go
Normal file
@@ -0,0 +1,357 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func parseAndValidateEnvConfig(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
if _, exists := os.LookupEnv("ENCRYPTION_KEY"); !exists {
|
||||
t.Setenv("ENCRYPTION_KEY", "0123456789abcdef")
|
||||
}
|
||||
|
||||
if err := parseEnvConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ValidateEnvConfig(&EnvConfig)
|
||||
}
|
||||
|
||||
func TestParseEnvConfig(t *testing.T) {
|
||||
// Store original config to restore later
|
||||
originalConfig := EnvConfig
|
||||
t.Cleanup(func() {
|
||||
EnvConfig = originalConfig
|
||||
})
|
||||
|
||||
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
|
||||
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
|
||||
})
|
||||
|
||||
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("APP_URL", "https://example.com")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
|
||||
})
|
||||
|
||||
t.Run("should fail when ENCRYPTION_KEY is too short", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("ENCRYPTION_KEY", "short")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be at least 16 bytes long")
|
||||
})
|
||||
|
||||
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "€://not-a-valid-url")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "APP_URL is not a valid URL")
|
||||
})
|
||||
|
||||
t.Run("should fail when APP_URL contains path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000/path")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "APP_URL must not contain a path")
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
|
||||
})
|
||||
|
||||
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
|
||||
})
|
||||
|
||||
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("UI_CONFIG_DISABLED", "true")
|
||||
t.Setenv("METRICS_ENABLED", "true")
|
||||
t.Setenv("TRACING_ENABLED", "false")
|
||||
t.Setenv("TRUST_PROXY", "true")
|
||||
t.Setenv("ANALYTICS_DISABLED", "false")
|
||||
t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, EnvConfig.UiConfigDisabled)
|
||||
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy)
|
||||
assert.False(t, EnvConfig.AnalyticsDisabled)
|
||||
assert.False(t, EnvConfig.AllowInsecureCallbackURLs)
|
||||
})
|
||||
|
||||
t.Run("should parse trusted proxy IP addresses and CIDR ranges", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("TRUST_PROXY", "10.0.0.0/8, 192.168.1.10, ::1/128")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.TrustProxy)
|
||||
})
|
||||
|
||||
t.Run("should disable trusted proxies when set to false", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("TRUST_PROXY", "false")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, EnvConfig.TrustProxy)
|
||||
})
|
||||
|
||||
t.Run("should allow insecure callback URLs by default", func(t *testing.T) {
|
||||
assert.True(t, defaultConfig().AllowInsecureCallbackURLs)
|
||||
})
|
||||
|
||||
t.Run("should default audit log retention days to 90", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 90, EnvConfig.AuditLogRetentionDays)
|
||||
})
|
||||
|
||||
t.Run("should parse audit log retention days override", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "365")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 365, EnvConfig.AuditLogRetentionDays)
|
||||
})
|
||||
|
||||
t.Run("should fail when AUDIT_LOG_RETENTION_DAYS is non-positive", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("AUDIT_LOG_RETENTION_DAYS", "0")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
})
|
||||
|
||||
t.Run("should parse string environment variables correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
||||
t.Setenv("APP_URL", "https://prod.example.com")
|
||||
t.Setenv("APP_ENV", "PRODUCTION")
|
||||
t.Setenv("UPLOAD_PATH", "/custom/uploads")
|
||||
t.Setenv("PORT", "8080")
|
||||
t.Setenv("HOST", "LOCALHOST")
|
||||
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
|
||||
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
|
||||
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
|
||||
t.Setenv("ACTORS_PORT", "9999")
|
||||
t.Setenv("ACTORS_HOST", "LOCALHOST")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppEnvProduction, EnvConfig.AppEnv) // lowercased
|
||||
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
|
||||
assert.Equal(t, "8080", EnvConfig.Port)
|
||||
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
|
||||
assert.Equal(t, "9999", EnvConfig.ActorsPort)
|
||||
assert.Equal(t, "localhost", EnvConfig.ActorsHost) // lowercased
|
||||
})
|
||||
|
||||
t.Run("should normalize file backend and default upload path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("FILE_BACKEND", "FILESYSTEM")
|
||||
t.Setenv("UPLOAD_PATH", "")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "filesystem", EnvConfig.FileBackend)
|
||||
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid FILE_BACKEND value", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("FILE_BACKEND", "invalid")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS cert is set without key", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("TLS_CERT", "/path/to/cert.pem")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS key is set without cert", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("TLS_KEY", "/path/to/key.pem")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS cert file does not exist", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("TLS_CERT", "/nonexistent/cert.pem")
|
||||
|
||||
keyFile := t.TempDir() + "/key.pem"
|
||||
require.NoError(t, os.WriteFile(keyFile, []byte("key"), 0600))
|
||||
t.Setenv("TLS_KEY", keyFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_CERT_FILE not found")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS key file does not exist", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
certFile := t.TempDir() + "/cert.pem"
|
||||
require.NoError(t, os.WriteFile(certFile, []byte("cert"), 0600))
|
||||
t.Setenv("TLS_CERT", certFile)
|
||||
t.Setenv("TLS_KEY", "/nonexistent/key.pem")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_KEY_FILE not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
encryptionKeyFile := tempDir + "/encryption_key.txt"
|
||||
encryptionKeyContent := "test-encryption-key-123"
|
||||
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbConnFile := tempDir + "/db_connection.txt"
|
||||
dbConnContent := "postgres://user:pass@localhost/testdb" // #nosec G101 - test credential
|
||||
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
binaryKeyFile := tempDir + "/binary_key.bin"
|
||||
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should process toLower and file options", func(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
config.AppEnv = "STAGING"
|
||||
config.Host = "LOCALHOST"
|
||||
|
||||
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
||||
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
||||
|
||||
err := prepareEnvConfig(&config)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, AppEnv("staging"), config.AppEnv)
|
||||
assert.Equal(t, "localhost", config.Host)
|
||||
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
||||
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
||||
})
|
||||
|
||||
t.Run("should handle binary data correctly", func(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
|
||||
|
||||
err := prepareEnvConfig(&config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
|
||||
})
|
||||
|
||||
t.Run("should load TLS cert and key file contents", func(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
|
||||
certFile := tempDir + "/cert.pem"
|
||||
certContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
||||
err := os.WriteFile(certFile, []byte(certContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyFile := tempDir + "/key.pem"
|
||||
keyContent := "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"
|
||||
err = os.WriteFile(keyFile, []byte(keyContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Setenv("TLS_CERT_FILE", certFile)
|
||||
t.Setenv("TLS_KEY_FILE", keyFile)
|
||||
|
||||
err = prepareEnvConfig(&config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, certContent, config.TLSCertFile)
|
||||
assert.Equal(t, keyContent, config.TLSKeyFile)
|
||||
})
|
||||
}
|
||||
@@ -1,287 +1,279 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
type AppError interface {
|
||||
error
|
||||
|
||||
HttpStatusCode() int
|
||||
}
|
||||
|
||||
type AppErrorDescription interface {
|
||||
AppError
|
||||
|
||||
Description() string
|
||||
}
|
||||
|
||||
// Custom error types for various conditions
|
||||
|
||||
type AlreadyInUseError struct {
|
||||
Property string
|
||||
}
|
||||
|
||||
func (e *AlreadyInUseError) Error() string {
|
||||
return fmt.Sprintf("%s is already in use", e.Property)
|
||||
func (e AlreadyInUseError) Error() string {
|
||||
return e.Property + " is already in use"
|
||||
}
|
||||
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||
func (e AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type SetupAlreadyCompletedError struct{}
|
||||
func (e AlreadyInUseError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AlreadyInUseError
|
||||
x := &AlreadyInUseError{}
|
||||
return errors.As(target, &x)
|
||||
}
|
||||
|
||||
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||
type SetupNotAvailableError struct{}
|
||||
|
||||
func (e SetupNotAvailableError) Error() string { return "not found" }
|
||||
func (e SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type TokenInvalidOrExpiredError struct{}
|
||||
|
||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||
func (e TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
func (e TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type DeviceCodeInvalid struct{}
|
||||
|
||||
func (e DeviceCodeInvalid) Error() string {
|
||||
return "one time access code must be used on the device it was generated for"
|
||||
}
|
||||
func (e DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type TokenInvalidError struct{}
|
||||
|
||||
func (e *TokenInvalidError) Error() string {
|
||||
return "Token is invalid"
|
||||
}
|
||||
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
|
||||
func (e TokenInvalidError) Error() string { return "Token is invalid" }
|
||||
func (e TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OidcMissingAuthorizationError struct{}
|
||||
|
||||
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||
func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcGrantTypeNotSupportedError struct{}
|
||||
|
||||
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcMissingClientCredentialsError struct{}
|
||||
|
||||
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcClientSecretInvalidError struct{}
|
||||
|
||||
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcInvalidAuthorizationCodeError struct{}
|
||||
|
||||
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||
func (e OidcMissingAuthorizationError) Error() string { return "missing authorization" }
|
||||
func (e OidcMissingAuthorizationError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidCallbackURLError struct{}
|
||||
|
||||
func (e *OidcInvalidCallbackURLError) Error() string {
|
||||
func (e OidcInvalidCallbackURLError) Error() string {
|
||||
return "invalid callback URL, it might be necessary for an admin to fix this"
|
||||
}
|
||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||
func (e OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type FileTypeNotSupportedError struct{}
|
||||
|
||||
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type InvalidCredentialsError struct{}
|
||||
|
||||
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
|
||||
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
|
||||
func (e FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||
func (e FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type FileTooLargeError struct {
|
||||
MaxSize string
|
||||
}
|
||||
|
||||
func (e *FileTooLargeError) Error() string {
|
||||
func (e FileTooLargeError) Error() string {
|
||||
return fmt.Sprintf("The file can't be larger than %s", e.MaxSize)
|
||||
}
|
||||
func (e *FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||
func (e FileTooLargeError) HttpStatusCode() int { return http.StatusRequestEntityTooLarge }
|
||||
|
||||
type NotSignedInError struct{}
|
||||
|
||||
func (e *NotSignedInError) Error() string { return "You are not signed in" }
|
||||
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type MissingAccessToken struct{}
|
||||
|
||||
func (e *MissingAccessToken) Error() string { return "Missing access token" }
|
||||
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
func (e NotSignedInError) Error() string { return "You are not signed in" }
|
||||
func (e NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type MissingPermissionError struct{}
|
||||
|
||||
func (e *MissingPermissionError) Error() string {
|
||||
func (e MissingPermissionError) Error() string {
|
||||
return "You don't have permission to perform this action"
|
||||
}
|
||||
func (e *MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
func (e MissingPermissionError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type TooManyRequestsError struct{}
|
||||
|
||||
func (e *TooManyRequestsError) Error() string {
|
||||
return "Too many requests"
|
||||
}
|
||||
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||
func (e TooManyRequestsError) Error() string { return "Too many requests" }
|
||||
func (e TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||
|
||||
type ClientIdOrSecretNotProvidedError struct{}
|
||||
type UserIdNotProvidedError struct{}
|
||||
|
||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||
return "Client id or secret not provided"
|
||||
}
|
||||
func (e *ClientIdOrSecretNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e UserIdNotProvidedError) Error() string { return "User id not provided" }
|
||||
func (e UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UserNotFoundError struct{}
|
||||
|
||||
func (e UserNotFoundError) Error() string { return "User not found" }
|
||||
func (e UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type WrongFileTypeError struct {
|
||||
ExpectedFileType string
|
||||
}
|
||||
|
||||
func (e *WrongFileTypeError) Error() string {
|
||||
func (e WrongFileTypeError) Error() string {
|
||||
return fmt.Sprintf("File must be of type %s", e.ExpectedFileType)
|
||||
}
|
||||
func (e *WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e WrongFileTypeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type MissingSessionIdError struct{}
|
||||
|
||||
func (e *MissingSessionIdError) Error() string {
|
||||
return "Missing session id"
|
||||
}
|
||||
func (e *MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e MissingSessionIdError) Error() string { return "Missing session id" }
|
||||
func (e MissingSessionIdError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ReservedClaimError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *ReservedClaimError) Error() string {
|
||||
func (e ReservedClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is reserved and can't be used", e.Key)
|
||||
}
|
||||
func (e *ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e ReservedClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type DuplicateClaimError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *DuplicateClaimError) Error() string {
|
||||
func (e DuplicateClaimError) Error() string {
|
||||
return fmt.Sprintf("Claim %s is already defined", e.Key)
|
||||
}
|
||||
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type AccountEditNotAllowedError struct{}
|
||||
|
||||
func (e *AccountEditNotAllowedError) Error() string {
|
||||
return "You are not allowed to edit your account"
|
||||
}
|
||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidCodeVerifierError struct{}
|
||||
|
||||
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||
return "Invalid code verifier"
|
||||
}
|
||||
func (e *OidcInvalidCodeVerifierError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcMissingCodeChallengeError struct{}
|
||||
|
||||
func (e *OidcMissingCodeChallengeError) Error() string {
|
||||
return "Missing code challenge"
|
||||
}
|
||||
func (e *OidcMissingCodeChallengeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type LdapUserUpdateError struct{}
|
||||
|
||||
func (e *LdapUserUpdateError) Error() string {
|
||||
return "LDAP users can't be updated"
|
||||
}
|
||||
func (e *LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
func (e LdapUserUpdateError) Error() string { return "LDAP users can't be updated" }
|
||||
func (e LdapUserUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type LdapUserGroupUpdateError struct{}
|
||||
|
||||
func (e *LdapUserGroupUpdateError) Error() string {
|
||||
return "LDAP user groups can't be updated"
|
||||
}
|
||||
func (e *LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
func (e LdapUserGroupUpdateError) Error() string { return "LDAP user groups can't be updated" }
|
||||
func (e LdapUserGroupUpdateError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcAccessDeniedError struct{}
|
||||
|
||||
func (e *OidcAccessDeniedError) Error() string {
|
||||
return "You're not allowed to access this service"
|
||||
}
|
||||
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
func (e OidcAccessDeniedError) Error() string { return "You're not allowed to access this service" }
|
||||
func (e OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcClientIdNotMatchingError struct{}
|
||||
|
||||
func (e *OidcClientIdNotMatchingError) Error() string {
|
||||
func (e OidcClientIdNotMatchingError) Error() string {
|
||||
return "Client id in request doesn't match client id in token"
|
||||
}
|
||||
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcNoCallbackURLError struct{}
|
||||
|
||||
func (e *OidcNoCallbackURLError) Error() string {
|
||||
return "No callback URL provided"
|
||||
}
|
||||
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UiConfigDisabledError struct{}
|
||||
|
||||
func (e *UiConfigDisabledError) Error() string {
|
||||
func (e UiConfigDisabledError) Error() string {
|
||||
return "The configuration can't be changed since the UI configuration is disabled"
|
||||
}
|
||||
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
func (e UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type InvalidUUIDError struct{}
|
||||
|
||||
func (e *InvalidUUIDError) Error() string {
|
||||
return "Invalid UUID"
|
||||
}
|
||||
|
||||
type InvalidEmailError struct{}
|
||||
func (e InvalidUUIDError) Error() string { return "Invalid UUID" }
|
||||
func (e InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OneTimeAccessDisabledError struct{}
|
||||
|
||||
func (e *OneTimeAccessDisabledError) Error() string {
|
||||
return "One-time access is disabled"
|
||||
}
|
||||
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
func (e OneTimeAccessDisabledError) Error() string { return "One-time access is disabled" }
|
||||
func (e OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type InvalidAPIKeyError struct{}
|
||||
|
||||
func (e *InvalidAPIKeyError) Error() string {
|
||||
return "Invalid Api Key"
|
||||
}
|
||||
func (e InvalidAPIKeyError) Error() string { return "Invalid Api Key" }
|
||||
func (e InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type NoAPIKeyProvidedError struct{}
|
||||
|
||||
func (e *NoAPIKeyProvidedError) Error() string {
|
||||
return "No API Key Provided"
|
||||
}
|
||||
func (e NoAPIKeyProvidedError) Error() string { return "No API Key Provided" }
|
||||
func (e NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type APIKeyNotFoundError struct{}
|
||||
|
||||
func (e *APIKeyNotFoundError) Error() string {
|
||||
return "API Key Not Found"
|
||||
}
|
||||
func (e APIKeyNotFoundError) Error() string { return "API Key Not Found" }
|
||||
func (e APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type APIKeyNotExpiredError struct{}
|
||||
|
||||
func (e APIKeyNotExpiredError) Error() string { return "API Key is not expired yet" }
|
||||
func (e APIKeyNotExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type APIKeyExpirationDateError struct{}
|
||||
|
||||
func (e *APIKeyExpirationDateError) Error() string {
|
||||
func (e APIKeyExpirationDateError) Error() string {
|
||||
return "API Key expiration time must be in the future"
|
||||
}
|
||||
func (e APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInvalidRefreshTokenError struct{}
|
||||
type APIKeyAuthNotAllowedError struct{}
|
||||
|
||||
func (e *OidcInvalidRefreshTokenError) Error() string {
|
||||
return "refresh token is invalid or expired"
|
||||
func (e APIKeyAuthNotAllowedError) Error() string {
|
||||
return "API key authentication is not allowed for this endpoint"
|
||||
}
|
||||
func (e APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
type UserDisabledError struct{}
|
||||
|
||||
func (e UserDisabledError) Error() string { return "User account is disabled" }
|
||||
func (e UserDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type ValidationError struct{ Message string }
|
||||
|
||||
func (e ValidationError) Error() string { return e.Message }
|
||||
|
||||
func (e ValidationError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcDeviceCodeExpiredError struct{}
|
||||
|
||||
func (e OidcDeviceCodeExpiredError) Error() string { return "device code has expired" }
|
||||
func (e OidcDeviceCodeExpiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInvalidDeviceCodeError struct{}
|
||||
|
||||
func (e OidcInvalidDeviceCodeError) Error() string { return "invalid device code" }
|
||||
func (e OidcInvalidDeviceCodeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ReauthenticationRequiredError struct{}
|
||||
|
||||
func (e ReauthenticationRequiredError) Error() string { return "reauthentication required" }
|
||||
func (e ReauthenticationRequiredError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OpenSignupDisabledError struct{}
|
||||
|
||||
func (e OpenSignupDisabledError) Error() string { return "Open user signup is not enabled" }
|
||||
|
||||
func (e OpenSignupDisabledError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type ClientIdAlreadyExistsError struct{}
|
||||
|
||||
func (e ClientIdAlreadyExistsError) Error() string { return "Client ID already in use" }
|
||||
|
||||
func (e ClientIdAlreadyExistsError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UserEmailNotSetError struct{}
|
||||
|
||||
func (e UserEmailNotSetError) Error() string { return "The user does not have an email address set" }
|
||||
|
||||
func (e UserEmailNotSetError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type ImageNotFoundError struct{}
|
||||
|
||||
func (e ImageNotFoundError) Error() string { return "Image not found" }
|
||||
|
||||
func (e ImageNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type OidcPARRequiredError struct{}
|
||||
|
||||
func (e OidcPARRequiredError) Error() string {
|
||||
return "this client requires pushed authorization requests"
|
||||
}
|
||||
func (e OidcPARRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcMissingRefreshTokenError struct{}
|
||||
type InvalidEmailVerificationTokenError struct{}
|
||||
|
||||
func (e *OidcMissingRefreshTokenError) Error() string {
|
||||
return "refresh token is required"
|
||||
}
|
||||
func (e InvalidEmailVerificationTokenError) Error() string { return "Invalid email verification token" }
|
||||
|
||||
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type OidcMissingAuthorizationCodeError struct{}
|
||||
|
||||
func (e *OidcMissingAuthorizationCodeError) Error() string {
|
||||
return "authorization code is required"
|
||||
}
|
||||
|
||||
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
func (e InvalidEmailVerificationTokenError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
20
backend/internal/common/internal_errors.go
Normal file
20
backend/internal/common/internal_errors.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
// ErrUnsupportedActorMethod is returned by custom actors when the invoked method isn't supported
|
||||
type ErrUnsupportedActorMethod struct {
|
||||
Method string
|
||||
}
|
||||
|
||||
func (e ErrUnsupportedActorMethod) Error() string {
|
||||
return "method '" + e.Method + "' unsupported for actor invocation"
|
||||
}
|
||||
|
||||
func (e ErrUnsupportedActorMethod) Is(target error) bool {
|
||||
// Ignore the field method when checking if an error is of the type ErrUnsupportedActorMethod
|
||||
_, ok := errors.AsType[ErrUnsupportedActorMethod](target)
|
||||
return ok
|
||||
}
|
||||
5
backend/internal/common/reserved.go
Normal file
5
backend/internal/common/reserved.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package common
|
||||
|
||||
// StaticApiKeyUserID is the fixed ID of the synthetic admin user that the static API key authenticates as
|
||||
// It is excluded from real-user counts such as the initial-admin-setup check
|
||||
const StaticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
|
||||
9
backend/internal/common/version.go
Normal file
9
backend/internal/common/version.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package common
|
||||
|
||||
// Name is the name of the application
|
||||
const Name = "pocket-id"
|
||||
|
||||
// Version contains the Pocket ID version.
|
||||
//
|
||||
// It can be set at build time using -ldflags.
|
||||
var Version = "unknown"
|
||||
@@ -1,125 +0,0 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
|
||||
|
||||
// ApiKeyController manages API keys for authenticated users
|
||||
type ApiKeyController struct {
|
||||
apiKeyService *service.ApiKeyService
|
||||
}
|
||||
|
||||
// NewApiKeyController creates a new controller for API key management
|
||||
// @Summary API key management controller
|
||||
// @Description Initializes API endpoints for managing API keys
|
||||
// @Tags API Keys
|
||||
func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
|
||||
uc := &ApiKeyController{apiKeyService: apiKeyService}
|
||||
|
||||
apiKeyGroup := group.Group("/api-keys")
|
||||
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
|
||||
{
|
||||
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// listApiKeysHandler godoc
|
||||
// @Summary List API keys
|
||||
// @Description Get a paginated list of API keys belonging to the current user
|
||||
// @Tags API Keys
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||
// @Router /api/api-keys [get]
|
||||
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var apiKeysDto []dto.ApiKeyDto
|
||||
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
|
||||
Data: apiKeysDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
// createApiKeyHandler godoc
|
||||
// @Summary Create API key
|
||||
// @Description Create a new API key for the current user
|
||||
// @Tags API Keys
|
||||
// @Param api_key body dto.ApiKeyCreateDto true "API key information"
|
||||
// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
|
||||
// @Router /api/api-keys [post]
|
||||
func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
|
||||
var input dto.ApiKeyCreateDto
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
|
||||
if err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var apiKeyDto dto.ApiKeyDto
|
||||
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{
|
||||
ApiKey: apiKeyDto,
|
||||
Token: token,
|
||||
})
|
||||
}
|
||||
|
||||
// revokeApiKeyHandler godoc
|
||||
// @Summary Revoke API key
|
||||
// @Description Revoke (delete) an existing API key by ID
|
||||
// @Tags API Keys
|
||||
// @Param id path string true "API Key ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/api-keys/{id} [delete]
|
||||
func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
apiKeyID := ctx.Param("id")
|
||||
|
||||
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user