mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 14:53:00 +03:00
Compare commits
491 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
5dcf69e974 | ||
|
|
519d58d88c | ||
|
|
b3b43a56af | ||
|
|
fc68cf7eb2 | ||
|
|
8ca7873802 | ||
|
|
591bf841f5 | ||
|
|
8f8884d208 | ||
|
|
7e658276f0 | ||
|
|
583a1f8fee | ||
|
|
b935a4824a | ||
|
|
cbd1bbdf74 | ||
|
|
96876a99c5 | ||
|
|
5c198c280c | ||
|
|
c9e0073b63 | ||
|
|
6fa26c97be | ||
|
|
6746dbf41e | ||
|
|
4ac1196d8d | ||
|
|
4d049bbe24 |
@@ -1,32 +1,21 @@
|
|||||||
// 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",
|
"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",
|
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||||
"features": {
|
"features": {
|
||||||
"ghcr.io/devcontainers/features/go:1": {},
|
"ghcr.io/devcontainers/features/go:1": {}
|
||||||
"ghcr.io/devcontainers-extra/features/caddy:1": {}
|
|
||||||
},
|
},
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
"golang.go",
|
"golang.go",
|
||||||
"svelte.svelte-vscode"
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
// Use 'postCreateCommand' to run commands after the container is created.
|
"containerEnv": {
|
||||||
// Install npm dependencies for the frontend.
|
"HOST": "0.0.0.0"
|
||||||
"postCreateCommand": "npm install --prefix frontend"
|
},
|
||||||
|
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
|
||||||
|
}
|
||||||
// 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"
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
node_modules
|
**/node_modules
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
@@ -6,6 +6,9 @@ node_modules
|
|||||||
/frontend/.svelte-kit
|
/frontend/.svelte-kit
|
||||||
/frontend/build
|
/frontend/build
|
||||||
/backend/bin
|
/backend/bin
|
||||||
|
/backend/frontend/dist
|
||||||
|
/tests/.auth
|
||||||
|
/tests/.report
|
||||||
|
|
||||||
|
|
||||||
# Env
|
# Env
|
||||||
@@ -15,4 +18,5 @@ node_modules
|
|||||||
|
|
||||||
# Application specific
|
# Application specific
|
||||||
data
|
data
|
||||||
/scripts/development
|
/scripts/development
|
||||||
|
/backend/GeoLite2-City.mmdb
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# See the README for more information: https://github.com/pocket-id/pocket-id?tab=readme-ov-file#environment-variables
|
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
|
||||||
PUBLIC_APP_URL=http://localhost
|
APP_URL=https://your-pocket-id-domain.com
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
MAXMIND_LICENSE_KEY=
|
MAXMIND_LICENSE_KEY=
|
||||||
PUID=1000
|
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
|
# 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"
|
name: "🐛 Bug Report"
|
||||||
description: "Report something that is not working as expected"
|
description: "Report something that is not working as expected"
|
||||||
title: "🐛 Bug Report: "
|
title: "🐛 Bug Report: "
|
||||||
labels: [bug]
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@@ -36,13 +36,29 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
### Additional Information
|
### Additional Information
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: extra-information
|
id: version
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
attributes:
|
attributes:
|
||||||
label: "Version and Environment"
|
label: "Pocket ID Version"
|
||||||
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
description: "Please specify the version of Pocket ID."
|
||||||
placeholder: "e.g., v0.24.1"
|
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
|
- type: textarea
|
||||||
id: log-files
|
id: log-files
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: 🚀 Feature
|
name: 🚀 Feature
|
||||||
description: "Submit a proposal for a new feature"
|
description: "Submit a proposal for a new feature"
|
||||||
title: "🚀 Feature: "
|
title: "🚀 Feature: "
|
||||||
labels: [feature]
|
type: 'Feature'
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature-description
|
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"
|
name: "🌐 Language request"
|
||||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||||
title: "🌐 Language Request: <language name in english>"
|
title: "🌐 Language Request: <language name in english>"
|
||||||
labels: [language-request]
|
type: 'Language Request'
|
||||||
body:
|
body:
|
||||||
- type: input
|
- type: input
|
||||||
id: language-name-native
|
id: language-name-native
|
||||||
|
|||||||
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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
40
.github/workflows/backend-linter.yml
vendored
Normal file
40
.github/workflows/backend-linter.yml
vendored
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: Run Backend Linter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
paths:
|
||||||
|
- "backend/**"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
# Required: allow read access to the content for analysis.
|
||||||
|
contents: read
|
||||||
|
# Optional: allow read access to pull request. Use with `only-new-issues` option.
|
||||||
|
pull-requests: read
|
||||||
|
# Optional: allow write access to checks to allow the action to annotate code in the PR.
|
||||||
|
checks: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
golangci-lint:
|
||||||
|
name: Run Golangci-lint
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Set up Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: backend/go.mod
|
||||||
|
|
||||||
|
- name: Run Golangci-lint
|
||||||
|
uses: golangci/golangci-lint-action@v8.0.0
|
||||||
|
with:
|
||||||
|
version: v2.4.0
|
||||||
|
args: --build-tags=exclude_frontend
|
||||||
|
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
|
|
||||||
96
.github/workflows/build-next.yml
vendored
Normal file
96
.github/workflows/build-next.yml
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
name: Build Next Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-next-image
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-next:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: "backend/go.mod"
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Set DOCKER_IMAGE_NAME
|
||||||
|
run: |
|
||||||
|
# Lowercase REPO_OWNER which is required for containers
|
||||||
|
REPO_OWNER=${{ github.repository_owner }}
|
||||||
|
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
||||||
|
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: sh scripts/development/build-binaries.sh --docker-only
|
||||||
|
|
||||||
|
- name: Build and push container image
|
||||||
|
id: build-push-image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
|
||||||
|
file: docker/Dockerfile-prebuilt
|
||||||
|
- name: Build and push container image (distroless)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
id: container-build-push-distroless
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
|
||||||
|
file: docker/Dockerfile-distroless
|
||||||
|
- name: Container image attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||||
|
subject-digest: ${{ steps.build-push-image.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
- name: Container image attestation (distroless)
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||||
|
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
203
.github/workflows/e2e-tests.yml
vendored
203
.github/workflows/e2e-tests.yml
vendored
@@ -17,140 +17,145 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Build and export
|
- name: Build and export
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
with:
|
with:
|
||||||
tags: pocket-id/pocket-id:test
|
context: .
|
||||||
|
file: docker/Dockerfile
|
||||||
|
push: false
|
||||||
|
load: false
|
||||||
|
tags: pocket-id:test
|
||||||
outputs: type=docker,dest=/tmp/docker-image.tar
|
outputs: type=docker,dest=/tmp/docker-image.tar
|
||||||
|
build-args: BUILD_TAGS=e2etest
|
||||||
|
cache-from: type=gha
|
||||||
|
cache-to: type=gha,mode=max
|
||||||
|
|
||||||
- name: Upload Docker image artifact
|
- name: Upload Docker image artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: docker-image
|
name: docker-image
|
||||||
path: /tmp/docker-image.tar
|
path: /tmp/docker-image.tar
|
||||||
|
retention-days: 1
|
||||||
|
|
||||||
test-sqlite:
|
test:
|
||||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
db: [sqlite, postgres]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: lts/*
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- name: Cache Playwright Browsers
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: playwright-cache
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
|
- name: Cache PostgreSQL Docker image
|
||||||
|
if: matrix.db == 'postgres'
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: postgres-cache
|
||||||
|
with:
|
||||||
|
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 from cache
|
||||||
|
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
|
||||||
|
run: docker load < /tmp/postgres-image.tar
|
||||||
|
- name: Cache LLDAP Docker image
|
||||||
|
uses: actions/cache@v3
|
||||||
|
id: lldap-cache
|
||||||
|
with:
|
||||||
|
path: /tmp/lldap-image.tar
|
||||||
|
key: lldap-stable-${{ runner.os }}
|
||||||
|
|
||||||
|
- name: Pull and save LLDAP image
|
||||||
|
if: steps.lldap-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
docker pull nitnelave/lldap:stable
|
||||||
|
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
|
||||||
|
|
||||||
|
- name: Load LLDAP image from cache
|
||||||
|
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
||||||
|
run: docker load < /tmp/lldap-image.tar
|
||||||
- name: Download Docker image artifact
|
- name: Download Docker image artifact
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: docker-image
|
name: docker-image
|
||||||
path: /tmp
|
path: /tmp
|
||||||
- name: Load Docker Image
|
|
||||||
|
- name: Load Docker image
|
||||||
run: docker load -i /tmp/docker-image.tar
|
run: docker load -i /tmp/docker-image.tar
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install test dependencies
|
||||||
working-directory: ./frontend
|
run: pnpm --filter pocket-id-tests install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
working-directory: ./frontend
|
working-directory: ./tests
|
||||||
run: npx playwright install --with-deps chromium
|
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 Container (sqlite) with LDAP
|
||||||
|
if: matrix.db == 'sqlite'
|
||||||
|
working-directory: ./tests/setup
|
||||||
run: |
|
run: |
|
||||||
docker run -d --name pocket-id-sqlite \
|
docker compose up -d
|
||||||
-p 80:80 \
|
docker compose logs -f pocket-id &> /tmp/backend.log &
|
||||||
-e APP_ENV=test \
|
|
||||||
pocket-id/pocket-id:test
|
- name: Run Docker Container (postgres) with LDAP
|
||||||
|
if: matrix.db == 'postgres'
|
||||||
|
working-directory: ./tests/setup
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose-postgres.yml up -d
|
||||||
|
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
working-directory: ./frontend
|
working-directory: ./tests
|
||||||
run: npx playwright test
|
run: pnpm exec playwright test
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
- name: Upload Test Report
|
||||||
if: always()
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
|
||||||
name: playwright-report-sqlite
|
|
||||||
path: frontend/tests/.report
|
|
||||||
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 POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
|
|
||||||
pocket-id/pocket-id:test
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
|
||||||
working-directory: ./frontend
|
|
||||||
run: npx playwright test
|
|
||||||
|
|
||||||
- uses: actions/upload-artifact@v4
|
|
||||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
with:
|
with:
|
||||||
name: playwright-report-postgres
|
name: playwright-report-${{ matrix.db }}
|
||||||
path: frontend/tests/.report
|
path: tests/.report
|
||||||
|
include-hidden-files: true
|
||||||
|
retention-days: 15
|
||||||
|
|
||||||
|
- name: Upload Backend Test Report
|
||||||
|
uses: actions/upload-artifact@v4
|
||||||
|
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
|
with:
|
||||||
|
name: backend-${{ matrix.db }}
|
||||||
|
path: /tmp/backend.log
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
|
|||||||
125
.github/workflows/release.yml
vendored
Normal file
125
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "v*.*.*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
packages: write
|
||||||
|
attestations: write
|
||||||
|
id-token: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
- uses: actions/setup-go@v6
|
||||||
|
with:
|
||||||
|
go-version-file: "backend/go.mod"
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
- name: Set DOCKER_IMAGE_NAME
|
||||||
|
run: |
|
||||||
|
# Lowercase REPO_OWNER which is required for containers
|
||||||
|
REPO_OWNER=${{ github.repository_owner }}
|
||||||
|
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
||||||
|
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{github.repository_owner}}
|
||||||
|
password: ${{secrets.GITHUB_TOKEN}}
|
||||||
|
- name: Docker metadata
|
||||||
|
id: meta
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_NAME }}
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},prefix=v
|
||||||
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
type=semver,pattern={{major}},prefix=v
|
||||||
|
- name: Docker metadata (distroless)
|
||||||
|
id: meta-distroless
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_NAME }}
|
||||||
|
flavor: |
|
||||||
|
suffix=-distroless,onlatest=true
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},prefix=v
|
||||||
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
type=semver,pattern={{major}},prefix=v
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||||
|
- name: Build frontend
|
||||||
|
run: pnpm --filter pocket-id-frontend build
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: sh scripts/development/build-binaries.sh
|
||||||
|
- name: Build and push container image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
id: container-build-push
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
file: docker/Dockerfile-prebuilt
|
||||||
|
- name: Build and push container image (distroless)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
id: container-build-push-distroless
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-distroless.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-distroless.outputs.labels }}
|
||||||
|
file: docker/Dockerfile-distroless
|
||||||
|
- name: Binary attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-path: "backend/.bin/pocket-id-**"
|
||||||
|
- name: Container image attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||||
|
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
- name: Container image attestation (distroless)
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||||
|
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
- name: Upload binaries to release
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
run: gh release upload ${{ github.ref_name }} backend/.bin/*
|
||||||
|
|
||||||
|
publish-release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs: [build]
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
- name: Mark release as published
|
||||||
|
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||||
59
.github/workflows/svelte-check.yml
vendored
Normal file
59
.github/workflows/svelte-check.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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]
|
||||||
|
paths:
|
||||||
|
- "frontend/src/**"
|
||||||
|
- ".github/svelte-check-matcher.json"
|
||||||
|
- "frontend/package.json"
|
||||||
|
- "frontend/package-lock.json"
|
||||||
|
- "frontend/tsconfig.json"
|
||||||
|
- "frontend/svelte.config.js"
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
type-check:
|
||||||
|
name: Run Svelte Check
|
||||||
|
# Don't run on dependabot branches
|
||||||
|
if: github.actor != 'dependabot[bot]'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
checks: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v5
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
|
||||||
|
- 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
|
||||||
18
.github/workflows/unit-tests.yml
vendored
18
.github/workflows/unit-tests.yml
vendored
@@ -2,22 +2,25 @@ name: Unit Tests
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "backend/**"
|
- "backend/**"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
test-backend:
|
test-backend:
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v6
|
||||||
with:
|
with:
|
||||||
go-version-file: 'backend/go.mod'
|
go-version-file: "backend/go.mod"
|
||||||
cache-dependency-path: 'backend/go.sum'
|
cache-dependency-path: "backend/go.sum"
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: |
|
run: |
|
||||||
@@ -25,7 +28,8 @@ jobs:
|
|||||||
- name: Run backend unit tests
|
- name: Run backend unit tests
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
run: |
|
run: |
|
||||||
go test -v ./... | tee /tmp/TestResults.log
|
set -e -o pipefail
|
||||||
|
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
|
||||||
- uses: actions/upload-artifact@v4
|
- uses: actions/upload-artifact@v4
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
|
|||||||
27
.github/workflows/update-aaguids.yml
vendored
27
.github/workflows/update-aaguids.yml
vendored
@@ -5,16 +5,17 @@ on:
|
|||||||
- cron: "0 0 * * 1" # Runs every Monday at midnight
|
- cron: "0 0 * * 1" # Runs every Monday at midnight
|
||||||
workflow_dispatch: # Allows manual triggering of the workflow
|
workflow_dispatch: # Allows manual triggering of the workflow
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
update-aaguids:
|
update-aaguids:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Fetch JSON data
|
- name: Fetch JSON data
|
||||||
run: |
|
run: |
|
||||||
@@ -24,11 +25,15 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
mkdir -p backend/resources
|
mkdir -p backend/resources
|
||||||
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
|
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
|
||||||
|
rm data.json
|
||||||
|
|
||||||
- name: Commit changes
|
- name: Create Pull Request
|
||||||
run: |
|
uses: peter-evans/create-pull-request@v7
|
||||||
git config --local user.email "action@github.com"
|
with:
|
||||||
git config --local user.name "GitHub Action"
|
commit-message: "chore: update AAGUIDs"
|
||||||
git add backend/resources/aaguids.json
|
title: "chore: update AAGUIDs"
|
||||||
git diff --staged --quiet || git commit -m "chore: update AAGUIDs"
|
body: |
|
||||||
git push
|
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
|
||||||
|
|||||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -9,6 +9,8 @@ node_modules
|
|||||||
/frontend/.svelte-kit
|
/frontend/.svelte-kit
|
||||||
/frontend/build
|
/frontend/build
|
||||||
/backend/bin
|
/backend/bin
|
||||||
|
pocket-id
|
||||||
|
/tests/test-results/*.json
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
@@ -17,7 +19,7 @@ Thumbs.db
|
|||||||
# Env
|
# Env
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.development-example
|
||||||
!.env.test
|
!.env.test
|
||||||
|
|
||||||
# Vite
|
# Vite
|
||||||
@@ -30,13 +32,15 @@ vite.config.ts.timestamp-*
|
|||||||
*.dll
|
*.dll
|
||||||
*.so
|
*.so
|
||||||
*.dylib
|
*.dylib
|
||||||
|
/backend/.bin
|
||||||
|
/pocket-id
|
||||||
|
|
||||||
# Application specific
|
# Application specific
|
||||||
data
|
data
|
||||||
/frontend/tests/.auth
|
/tests/.auth
|
||||||
/frontend/tests/.report
|
/tests/.report
|
||||||
pocket-id-backend
|
|
||||||
/backend/GeoLite2-City.mmdb
|
/backend/GeoLite2-City.mmdb
|
||||||
|
/backend/frontend/dist
|
||||||
|
|
||||||
# Misc
|
# Misc
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@@ -1,5 +1,8 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"inlang.vs-code-extension"
|
"golang.go",
|
||||||
|
"svelte.svelte-vscode",
|
||||||
|
"bradlc.vscode-tailwindcss",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -5,7 +5,7 @@
|
|||||||
"name": "Backend",
|
"name": "Backend",
|
||||||
"type": "go",
|
"type": "go",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"envFile": "${workspaceFolder}/backend/.env.example",
|
"envFile": "${workspaceFolder}/backend/cmd/.env",
|
||||||
"env": {
|
"env": {
|
||||||
"APP_ENV": "development"
|
"APP_ENV": "development"
|
||||||
},
|
},
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"name": "Frontend",
|
"name": "Frontend",
|
||||||
"type": "node",
|
"type": "node",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"envFile": "${workspaceFolder}/frontend/.env.example",
|
"envFile": "${workspaceFolder}/frontend/.env",
|
||||||
"cwd": "${workspaceFolder}/frontend",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"runtimeExecutable": "npm",
|
"runtimeExecutable": "npm",
|
||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
|
|||||||
4
.vscode/settings.json
vendored
Normal file
4
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"go.buildTags": "e2etest",
|
||||||
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
1857
CHANGELOG.md
1857
CHANGELOG.md
File diff suppressed because it is too large
Load Diff
@@ -17,7 +17,7 @@ Before you submit the pull request for review please ensure that
|
|||||||
example:
|
example:
|
||||||
|
|
||||||
```
|
```
|
||||||
feat(share): add password protection
|
fix: hide global audit log switch for non admin users
|
||||||
```
|
```
|
||||||
|
|
||||||
Where `TYPE` can be:
|
Where `TYPE` can be:
|
||||||
@@ -28,57 +28,69 @@ Before you submit the pull request for review please ensure that
|
|||||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||||
|
|
||||||
- Your pull request has a detailed description
|
- 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
|
## Development Environment
|
||||||
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
|
|
||||||
|
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
|
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
|
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
|
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.
|
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/) >= 22
|
||||||
|
- [Go](https://golang.org/doc/install) >= 1.25
|
||||||
|
- [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
|
1. Open the `backend` folder
|
||||||
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
|
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
|
||||||
3. Start the backend with `go run cmd/main.go`
|
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
|
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
|
||||||
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))
|
|
||||||
|
|
||||||
### Testing
|
### Testing
|
||||||
|
|
||||||
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
||||||
|
|
||||||
|
If you are contributing to a new feature please ensure that you add tests for it. The tests are located in the `tests` folder at the root of the project.
|
||||||
|
|
||||||
The tests can be run like this:
|
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`
|
1. Install the dependencies from the root of the project `pnpm install`
|
||||||
3. Run the tests with `npm run test`
|
|
||||||
|
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.
|
||||||
|
|||||||
44
Dockerfile
44
Dockerfile
@@ -1,44 +0,0 @@
|
|||||||
# 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
|
|
||||||
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 -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"]
|
|
||||||
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
|
||||||
9
backend/.env.development-example
Normal file
9
backend/.env.development-example
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# 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
|
||||||
@@ -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/
|
# vendor/
|
||||||
./data
|
./data
|
||||||
.env
|
.env
|
||||||
|
pocket-id
|
||||||
64
backend/.golangci.yml
Normal file
64
backend/.golangci.yml
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
version: "2"
|
||||||
|
run:
|
||||||
|
tests: true
|
||||||
|
timeout: 5m
|
||||||
|
linters:
|
||||||
|
default: none
|
||||||
|
enable:
|
||||||
|
- asasalint
|
||||||
|
- asciicheck
|
||||||
|
- bidichk
|
||||||
|
- bodyclose
|
||||||
|
- contextcheck
|
||||||
|
- copyloopvar
|
||||||
|
- durationcheck
|
||||||
|
- errcheck
|
||||||
|
- errchkjson
|
||||||
|
- errorlint
|
||||||
|
- exhaustive
|
||||||
|
- gocheckcompilerdirectives
|
||||||
|
- gochecksumtype
|
||||||
|
- gocognit
|
||||||
|
- gocritic
|
||||||
|
- gosec
|
||||||
|
- gosmopolitan
|
||||||
|
- govet
|
||||||
|
- ineffassign
|
||||||
|
- loggercheck
|
||||||
|
- makezero
|
||||||
|
- musttag
|
||||||
|
- nilerr
|
||||||
|
- nilnesserr
|
||||||
|
- noctx
|
||||||
|
- protogetter
|
||||||
|
- reassign
|
||||||
|
- recvcheck
|
||||||
|
- rowserrcheck
|
||||||
|
- spancheck
|
||||||
|
- sqlclosecheck
|
||||||
|
- staticcheck
|
||||||
|
- testifylint
|
||||||
|
- unused
|
||||||
|
- usestdlibvars
|
||||||
|
- zerologlint
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
presets:
|
||||||
|
- comments
|
||||||
|
- common-false-positives
|
||||||
|
- legacy
|
||||||
|
- std-error-handling
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
|
- internal/service/test_service.go
|
||||||
|
formatters:
|
||||||
|
enable:
|
||||||
|
- goimports
|
||||||
|
exclusions:
|
||||||
|
generated: lax
|
||||||
|
paths:
|
||||||
|
- third_party$
|
||||||
|
- builtin$
|
||||||
|
- examples$
|
||||||
@@ -1,9 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
_ "time/tzdata"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/cmds"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// @title Pocket ID API
|
||||||
|
// @version 1.0
|
||||||
|
// @description.markdown
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
bootstrap.Bootstrap()
|
cmds.Execute()
|
||||||
}
|
}
|
||||||
|
|||||||
9
backend/frontend/frontend_excluded.go
Normal file
9
backend/frontend/frontend_excluded.go
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
//go:build exclude_frontend
|
||||||
|
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import "github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
func RegisterFrontend(router *gin.Engine) error {
|
||||||
|
return ErrFrontendNotIncluded
|
||||||
|
}
|
||||||
149
backend/frontend/frontend_included.go
Normal file
149
backend/frontend/frontend_included.go
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
//go:build !exclude_frontend
|
||||||
|
|
||||||
|
package frontend
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"embed"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"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))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the position of the first <script> tag
|
||||||
|
idx := bytes.Index(index, []byte(scriptTag))
|
||||||
|
|
||||||
|
// Create writeIndexFn, which adds the CSP tag to the script tag if needed
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// We have a nonce, so first write the index until the <script> tag
|
||||||
|
// Then we write the modified script tag
|
||||||
|
// Finally, the rest of the index
|
||||||
|
_, err = w.Write(index[0:idx])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write([]byte(`<script nonce="` + nonce + `">`))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = w.Write(index[(idx + len(scriptTag)):])
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
cacheMaxAge := time.Hour * 24
|
||||||
|
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
||||||
|
|
||||||
|
router.NoRoute(func(c *gin.Context) {
|
||||||
|
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
|
|
||||||
|
if strings.HasPrefix(path, "api/") {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// If path is / or does not exist, serve index.html
|
||||||
|
if path == "" {
|
||||||
|
path = "index.html"
|
||||||
|
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
|
||||||
|
path = "index.html"
|
||||||
|
}
|
||||||
|
|
||||||
|
if path == "index.html" {
|
||||||
|
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)
|
||||||
|
|
||||||
|
err = writeIndexFn(c.Writer, nonce)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Serve other static assets with caching
|
||||||
|
c.Request.URL.Path = "/" + path
|
||||||
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// FileServerWithCaching wraps http.FileServer to add caching headers
|
||||||
|
type FileServerWithCaching struct {
|
||||||
|
root http.FileSystem
|
||||||
|
lastModified time.Time
|
||||||
|
cacheMaxAge int
|
||||||
|
lastModifiedHeaderValue string
|
||||||
|
cacheControlHeaderValue string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
|
||||||
|
return &FileServerWithCaching{
|
||||||
|
root: root,
|
||||||
|
lastModified: time.Now(),
|
||||||
|
cacheMaxAge: maxAge,
|
||||||
|
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
|
||||||
|
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Check if the client has a cached version
|
||||||
|
if ifModifiedSince := r.Header.Get("If-Modified-Since"); 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
||||||
|
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
|
||||||
|
|
||||||
|
http.FileServer(f.root).ServeHTTP(w, r)
|
||||||
|
}
|
||||||
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")
|
||||||
137
backend/go.mod
137
backend/go.mod
@@ -1,88 +1,149 @@
|
|||||||
module github.com/pocket-id/pocket-id/backend
|
module github.com/pocket-id/pocket-id/backend
|
||||||
|
|
||||||
go 1.23.1
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3
|
||||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||||
github.com/emersion/go-smtp v0.21.3
|
github.com/emersion/go-smtp v0.21.3
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-contrib/slog v1.1.0
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0
|
github.com/gin-gonic/gin v1.10.1
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0
|
||||||
|
github.com/glebarez/sqlite v1.11.0
|
||||||
|
github.com/go-co-op/gocron/v2 v2.16.3
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10
|
github.com/go-ldap/ldap/v3 v3.4.10
|
||||||
github.com/go-playground/validator/v10 v10.24.0
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
github.com/go-webauthn/webauthn v0.11.2
|
github.com/go-webauthn/webauthn v0.11.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2
|
github.com/golang-migrate/migrate/v4 v4.18.3
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/hashicorp/go-uuid v1.0.3
|
||||||
|
github.com/jinzhu/copier v0.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3
|
github.com/lestrrat-go/httprc/v3 v3.0.0
|
||||||
|
github.com/lestrrat-go/jwx/v3 v3.0.10
|
||||||
|
github.com/lmittmann/tint v1.1.2
|
||||||
|
github.com/mattn/go-isatty v0.0.20
|
||||||
github.com/mileusna/useragent v1.3.5
|
github.com/mileusna/useragent v1.3.5
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
github.com/orandin/slog-gorm v1.4.0
|
||||||
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
|
||||||
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/stretchr/testify v1.10.0
|
github.com/stretchr/testify v1.10.0
|
||||||
golang.org/x/crypto v0.36.0
|
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
|
||||||
golang.org/x/image v0.24.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
||||||
golang.org/x/time v0.9.0
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
|
||||||
gorm.io/driver/sqlite v1.5.7
|
go.opentelemetry.io/otel v1.37.0
|
||||||
gorm.io/gorm v1.25.12
|
go.opentelemetry.io/otel/log v0.13.0
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.10.0
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0
|
||||||
|
golang.org/x/crypto v0.41.0
|
||||||
|
golang.org/x/image v0.30.0
|
||||||
|
golang.org/x/sync v0.16.0
|
||||||
|
golang.org/x/text v0.28.0
|
||||||
|
golang.org/x/time v0.12.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.30.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/bytedance/sonic v1.12.8 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/disintegration/gift v1.1.2 // indirect
|
github.com/disintegration/gift v1.1.2 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.7 // 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/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-webauthn/x v0.1.16 // indirect
|
github.com/go-webauthn/x v0.1.23 // indirect
|
||||||
github.com/goccy/go-json v0.10.4 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/google/go-tpm v0.9.3 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||||
|
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.5 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/kr/pretty v0.3.1 // indirect
|
|
||||||
github.com/leodido/go-urn v1.4.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/httpcc v1.0.1 // 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/lestrrat-go/option v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // 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/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||||
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
|
github.com/prometheus/common v0.62.0 // indirect
|
||||||
|
github.com/prometheus/procfs v0.15.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // 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.0 // indirect
|
||||||
|
github.com/valyala/fastjson v1.6.4 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
|
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.13.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||||
golang.org/x/net v0.36.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/sync v0.12.0 // indirect
|
golang.org/x/oauth2 v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.31.0 // indirect
|
golang.org/x/sys v0.35.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/protobuf v1.36.4 // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
|
google.golang.org/grpc v1.71.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.7 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
modernc.org/libc v1.66.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
328
backend/go.sum
328
backend/go.sum
@@ -6,24 +6,30 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
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 h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
|
||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
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/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
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 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
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.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
|
||||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
|
||||||
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
github.com/disintegration/gift v1.1.2/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 h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||||
@@ -38,28 +44,37 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
|||||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||||
|
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/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
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-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 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
|
||||||
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
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 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
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.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
|
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||||
|
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||||
|
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
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 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-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.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
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-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.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
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 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
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=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
@@ -68,29 +83,44 @@ 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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
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/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.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||||
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||||
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
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.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
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.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.5.2/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-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-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||||
|
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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/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/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
|
||||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
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 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
@@ -99,12 +129,14 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
|
|||||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
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-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
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/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
@@ -119,6 +151,8 @@ 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/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 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
|
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 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -129,28 +163,34 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
|
|||||||
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
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/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 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
|
||||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
|
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3 h1:HHT8iW+UcPBgBr5A3soZQQsL5cBor/u6BkLB+wzY/R0=
|
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.0-alpha3/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
|
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk=
|
||||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
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/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
|
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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||||
|
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
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-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 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
@@ -170,30 +210,48 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
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/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 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
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/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
|
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
|
||||||
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
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 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||||
|
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||||
|
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||||
|
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||||
|
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||||
|
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||||
|
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||||
|
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||||
|
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 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
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.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
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.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.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
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.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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -201,49 +259,96 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.1/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.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.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 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
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/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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||||
|
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
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/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
|
||||||
|
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
|
||||||
|
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
|
||||||
|
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
|
||||||
|
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0/go.mod h1:fPl+qlrhRdRntIpPs9JoQ0iBKAsnH5VkgppU1f9kyF4=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 h1:jj/B7eX95/mOxim9g9laNZkOHKz/XCHG0G410SntRy4=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
|
||||||
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 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
|
||||||
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
|
||||||
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0/go.mod h1:leO2CSTg0Y+LyvmR7Wm4pUxE8KAmaM2GCVx7O+RATLA=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 h1:0NIXxOCFx+SKbhCVxwl3ETG8ClLPAa0KuKV6p3yhxP8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0/go.mod h1:ChZSJbbfbl/DcRZNc9Gqh6DYGlfjw4PvO1pEOZH1ZsE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk=
|
||||||
|
go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 h1:GKCEAZLEpEf78cUvudQdTg0aET2ObOZRB2HtXA0qPAI=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0/go.mod h1:9/zqSWLCmHT/9Jo6fYeUDRRogOLL60ABLsHWS99lF8s=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
|
||||||
|
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
|
||||||
|
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
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.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
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.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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
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.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.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
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.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.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.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.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
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-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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -255,8 +360,11 @@ 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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
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.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.36.0 h1:vWF2fRbw4qslQsQzgFqZff+BItCvGFQqKzKIzx1rmoA=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.36.0/go.mod h1:bFmbeoIPfrw4sMHNhb4J9f6+tPziuGjq7Jk/38fxi1I=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||||
|
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -264,8 +372,8 @@ 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.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.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.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/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-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -278,8 +386,8 @@ 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.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.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.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
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-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.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -290,6 +398,7 @@ 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.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
@@ -298,19 +407,29 @@ 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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.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.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
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-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
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.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
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=
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||||
|
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||||
|
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||||
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
|
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/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 h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@@ -318,10 +437,33 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
|
||||||
|
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
|
||||||
|
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
|
||||||
|
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.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
|
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=
|
||||||
|
|||||||
120
backend/internal/bootstrap/app_images_bootstrap.go
Normal file
120
backend/internal/bootstrap/app_images_bootstrap.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
|
||||||
|
"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
|
||||||
|
// and returns a map containing the detected file extensions in the application-images directory.
|
||||||
|
func initApplicationImages() (map[string]string, error) {
|
||||||
|
// Previous versions of images
|
||||||
|
// If these are found, they are deleted
|
||||||
|
legacyImageHashes := imageHashMap{
|
||||||
|
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
|
||||||
|
}
|
||||||
|
|
||||||
|
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||||
|
|
||||||
|
sourceFiles, err := resources.FS.ReadDir("images")
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destinationFiles, err := os.ReadDir(dirPath)
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return nil, fmt.Errorf("failed to read directory: %w", err)
|
||||||
|
}
|
||||||
|
dstNameToExt := make(map[string]string, len(destinationFiles))
|
||||||
|
for _, f := range destinationFiles {
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := f.Name()
|
||||||
|
nameWithoutExt, ext := utils.SplitFileName(name)
|
||||||
|
destFilePath := path.Join(dirPath, name)
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if f.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
h, err := utils.CreateSha256FileHash(destFilePath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to get hash for file", slog.String("name", name), slog.Any("error", err))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the file is a legacy one - if so, delete it
|
||||||
|
if legacyImageHashes.Contains(h) {
|
||||||
|
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
||||||
|
err = os.Remove(destFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track existing files
|
||||||
|
dstNameToExt[nameWithoutExt] = ext
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
destFilePath := path.Join(dirPath, name)
|
||||||
|
|
||||||
|
// Skip if there's already an image at the path
|
||||||
|
// We do not check the extension because users could have uploaded a different one
|
||||||
|
if _, exists := dstNameToExt[nameWithoutExt]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Writing new application image", slog.String("name", name))
|
||||||
|
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to copy file: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track the newly copied file so it can be included in the extensions map later
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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,17 +1,76 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
|
||||||
|
"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/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Bootstrap() {
|
func Bootstrap(ctx context.Context) error {
|
||||||
initApplicationImages()
|
// Initialize the observability stack, including the logger, distributed tracing, and metrics
|
||||||
|
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
|
||||||
|
}
|
||||||
|
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||||
|
|
||||||
db := newDatabase()
|
imageExtensions, err := initApplicationImages()
|
||||||
appConfigService := service.NewAppConfigService(db)
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
migrateKey()
|
// Connect to the database
|
||||||
|
db, err := NewDatabase()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
initRouter(db, appConfigService)
|
// Create all services
|
||||||
|
svc, err := initServices(ctx, db, httpClient, imageExtensions)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize services: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init the job scheduler
|
||||||
|
scheduler, err := job.NewScheduler()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create job scheduler: %w", err)
|
||||||
|
}
|
||||||
|
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register scheduled jobs: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init the router
|
||||||
|
router := initRouter(db, svc)
|
||||||
|
|
||||||
|
// Run all background services
|
||||||
|
// This call blocks until the context is canceled
|
||||||
|
err = utils.
|
||||||
|
NewServiceRunner(router, scheduler.Run).
|
||||||
|
Run(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to run services: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Invoke all shutdown functions
|
||||||
|
// We give these a timeout of 5s
|
||||||
|
// Note: we use a background context because the run context has been canceled already
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
err = utils.
|
||||||
|
NewServiceRunner(shutdownFns...).
|
||||||
|
Run(shutdownCtx) //nolint:contextcheck
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error shutting down services", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +1,180 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"database/sql"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/glebarez/sqlite"
|
||||||
"github.com/golang-migrate/migrate/v4"
|
"github.com/golang-migrate/migrate/v4"
|
||||||
"github.com/golang-migrate/migrate/v4/database"
|
"github.com/golang-migrate/migrate/v4/database"
|
||||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
slogGorm "github.com/orandin/slog-gorm"
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/driver/sqlite"
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
gormLogger "gorm.io/gorm/logger"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newDatabase() (db *gorm.DB) {
|
func NewDatabase() (db *gorm.DB, err error) {
|
||||||
db, err := connectDatabase()
|
db, err = connectDatabase()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to connect to database: %v", err)
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
}
|
}
|
||||||
sqlDb, err := db.DB()
|
sqlDb, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get sql.DB: %v", err)
|
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose the correct driver for the database provider
|
// Choose the correct driver for the database provider
|
||||||
var driver database.Driver
|
var driver database.Driver
|
||||||
switch common.EnvConfig.DbProvider {
|
switch common.EnvConfig.DbProvider {
|
||||||
case common.DbProviderSqlite:
|
case common.DbProviderSqlite:
|
||||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
|
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
|
||||||
|
NoTxWrap: true,
|
||||||
|
})
|
||||||
case common.DbProviderPostgres:
|
case common.DbProviderPostgres:
|
||||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||||
default:
|
default:
|
||||||
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
// Should never happen at this point
|
||||||
|
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create migration driver: %v", err)
|
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
if err := migrateDatabase(driver); err != nil {
|
if err := migrateDatabase(driver); err != nil {
|
||||||
log.Fatalf("failed to run migrations: %v", err)
|
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateDatabase(driver database.Driver) error {
|
func migrateDatabase(driver database.Driver) error {
|
||||||
// Use the embedded migrations
|
// Embedded migrations via iofs
|
||||||
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
|
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||||
|
source, err := iofs.New(resources.FS, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create embedded migration source: %v", err)
|
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
|
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create migration instance: %v", err)
|
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = m.Up()
|
requiredVersion, err := getRequiredMigrationVersion(path)
|
||||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to apply migrations: %v", err)
|
return fmt.Errorf("failed to get last migration version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentVersion, _, _ := m.Version()
|
||||||
|
if currentVersion > requiredVersion {
|
||||||
|
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
|
||||||
|
if !common.EnvConfig.AllowDowngrade {
|
||||||
|
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
|
||||||
|
}
|
||||||
|
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||||
|
return migrateDatabaseFromGitHub(driver, requiredVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("failed to apply embedded migrations: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
|
||||||
|
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
|
||||||
|
|
||||||
|
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
|
||||||
|
func getRequiredMigrationVersion(path string) (uint, error) {
|
||||||
|
entries, err := resources.FS.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read migration directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxVersion uint
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
var version uint
|
||||||
|
n, err := fmt.Sscanf(name, "%d_", &version)
|
||||||
|
if err == nil && n == 1 {
|
||||||
|
if version > maxVersion {
|
||||||
|
maxVersion = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
func connectDatabase() (db *gorm.DB, err error) {
|
func connectDatabase() (db *gorm.DB, err error) {
|
||||||
var dialector gorm.Dialector
|
var dialector gorm.Dialector
|
||||||
|
|
||||||
// Choose the correct database provider
|
// Choose the correct database provider
|
||||||
|
var onConnFn func(conn *sql.DB)
|
||||||
switch common.EnvConfig.DbProvider {
|
switch common.EnvConfig.DbProvider {
|
||||||
case common.DbProviderSqlite:
|
case common.DbProviderSqlite:
|
||||||
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
|
if common.EnvConfig.DbConnectionString == "" {
|
||||||
|
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||||
|
}
|
||||||
|
|
||||||
|
sqliteutil.RegisterSqliteFunctions()
|
||||||
|
|
||||||
|
connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||||
|
err = ensureSqliteTempDir(filepath.Dir(dbPath))
|
||||||
|
if err != nil {
|
||||||
|
return 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:
|
case common.DbProviderPostgres:
|
||||||
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
|
if common.EnvConfig.DbConnectionString == "" {
|
||||||
|
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||||
|
}
|
||||||
|
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
|
||||||
default:
|
default:
|
||||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||||
}
|
}
|
||||||
@@ -88,37 +182,274 @@ func connectDatabase() (db *gorm.DB, err error) {
|
|||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
db, err = gorm.Open(dialector, &gorm.Config{
|
db, err = gorm.Open(dialector, &gorm.Config{
|
||||||
TranslateError: true,
|
TranslateError: true,
|
||||||
Logger: getLogger(),
|
Logger: getGormLogger(),
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
break
|
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
|
||||||
} else {
|
|
||||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
if onConnFn != nil {
|
||||||
time.Sleep(3 * time.Second)
|
conn, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||||
|
time.Sleep(3 * time.Second)
|
||||||
|
}
|
||||||
|
onConnFn(conn)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||||
|
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) {
|
||||||
|
if !strings.HasPrefix(connString, "file:") {
|
||||||
|
connString = "file:" + connString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're using an in-memory database
|
||||||
|
isMemoryDB = isSqliteInMemory(connString)
|
||||||
|
|
||||||
|
// Parse the connection string
|
||||||
|
connStringUrl, err := url.Parse(connString)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false, fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert options for the old SQLite driver to the new one
|
||||||
|
convertSqlitePragmaArgs(connStringUrl)
|
||||||
|
|
||||||
|
// Add the default and required params
|
||||||
|
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false, fmt.Errorf("invalid SQLite connection string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the absolute path to the database
|
||||||
|
// Here, we know for a fact that the ? is present
|
||||||
|
parsedConnString = connStringUrl.String()
|
||||||
|
idx := strings.IndexRune(parsedConnString, '?')
|
||||||
|
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", false, fmt.Errorf("failed to determine absolute path to the database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedConnString, dbPath, isMemoryDB, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// The official C implementation of SQLite allows some additional properties in the connection string
|
||||||
|
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
|
||||||
|
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
|
||||||
|
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
|
||||||
|
// Note this function updates connStringUrl.
|
||||||
|
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
|
||||||
|
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
|
||||||
|
// This only includes a subset of options, excluding those that are not relevant to us
|
||||||
|
qs := make(url.Values, len(connStringUrl.Query()))
|
||||||
|
for k, v := range connStringUrl.Query() {
|
||||||
|
switch strings.ToLower(k) {
|
||||||
|
case "_auto_vacuum", "_vacuum":
|
||||||
|
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
|
||||||
|
case "_busy_timeout", "_timeout":
|
||||||
|
qs.Add("_pragma", "busy_timeout("+v[0]+")")
|
||||||
|
case "_case_sensitive_like", "_cslike":
|
||||||
|
qs.Add("_pragma", "case_sensitive_like("+v[0]+")")
|
||||||
|
case "_foreign_keys", "_fk":
|
||||||
|
qs.Add("_pragma", "foreign_keys("+v[0]+")")
|
||||||
|
case "_locking_mode", "_locking":
|
||||||
|
qs.Add("_pragma", "locking_mode("+v[0]+")")
|
||||||
|
case "_secure_delete":
|
||||||
|
qs.Add("_pragma", "secure_delete("+v[0]+")")
|
||||||
|
case "_synchronous", "_sync":
|
||||||
|
qs.Add("_pragma", "synchronous("+v[0]+")")
|
||||||
|
default:
|
||||||
|
// Pass other query-string args as-is
|
||||||
|
qs[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return db, err
|
// Update the connStringUrl object
|
||||||
|
connStringUrl.RawQuery = qs.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLogger() logger.Interface {
|
// Adds the default (and some required) parameters to the SQLite connection string.
|
||||||
isProduction := common.EnvConfig.AppEnv == "production"
|
// Note this function updates connStringUrl.
|
||||||
|
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
|
||||||
|
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
|
||||||
|
// Copyright (C) 2023 The Dapr Authors
|
||||||
|
// License: Apache2
|
||||||
|
const defaultBusyTimeout = 2500 * time.Millisecond
|
||||||
|
|
||||||
var logLevel logger.LogLevel
|
// Get the "query string" from the connection string if present
|
||||||
if isProduction {
|
qs := connStringUrl.Query()
|
||||||
logLevel = logger.Error
|
if len(qs) == 0 {
|
||||||
} else {
|
qs = make(url.Values, 2)
|
||||||
logLevel = logger.Info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return logger.New(
|
// Check if the database is read-only or immutable
|
||||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
isReadOnly := false
|
||||||
logger.Config{
|
if len(qs["mode"]) > 0 {
|
||||||
SlowThreshold: 200 * time.Millisecond,
|
// Keep the first value only
|
||||||
LogLevel: logLevel,
|
qs["mode"] = []string{
|
||||||
IgnoreRecordNotFoundError: isProduction,
|
strings.ToLower(qs["mode"][0]),
|
||||||
ParameterizedQueries: isProduction,
|
}
|
||||||
Colorful: !isProduction,
|
if qs["mode"][0] == "ro" {
|
||||||
},
|
isReadOnly = true
|
||||||
)
|
}
|
||||||
|
}
|
||||||
|
if len(qs["immutable"]) > 0 {
|
||||||
|
// Keep the first value only
|
||||||
|
qs["immutable"] = []string{
|
||||||
|
strings.ToLower(qs["immutable"][0]),
|
||||||
|
}
|
||||||
|
if qs["immutable"][0] == "1" {
|
||||||
|
isReadOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
|
||||||
|
if len(qs["_txlock"]) > 0 {
|
||||||
|
// Keep the first value only
|
||||||
|
qs["_txlock"] = []string{
|
||||||
|
strings.ToLower(qs["_txlock"][0]),
|
||||||
|
}
|
||||||
|
if qs["_txlock"][0] != "immediate" {
|
||||||
|
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qs["_txlock"] = []string{"immediate"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pragma values
|
||||||
|
var hasBusyTimeout, hasJournalMode bool
|
||||||
|
if len(qs["_pragma"]) == 0 {
|
||||||
|
qs["_pragma"] = make([]string, 0, 3)
|
||||||
|
} else {
|
||||||
|
for _, p := range qs["_pragma"] {
|
||||||
|
p = strings.ToLower(p)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, "busy_timeout"):
|
||||||
|
hasBusyTimeout = true
|
||||||
|
case strings.HasPrefix(p, "journal_mode"):
|
||||||
|
hasJournalMode = true
|
||||||
|
case strings.HasPrefix(p, "foreign_keys"):
|
||||||
|
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasBusyTimeout {
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
|
||||||
|
}
|
||||||
|
if !hasJournalMode {
|
||||||
|
switch {
|
||||||
|
case isMemoryDB:
|
||||||
|
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
|
||||||
|
case isReadOnly:
|
||||||
|
// Set the journaling mode to "DELETE" (the default) if the database is read-only
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
|
||||||
|
default:
|
||||||
|
// Enable WAL
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forcefully enable foreign keys
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
|
||||||
|
|
||||||
|
// Update the connStringUrl object
|
||||||
|
connStringUrl.RawQuery = qs.Encode()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSqliteInMemory returns true if the connection string is for an in-memory database.
|
||||||
|
func isSqliteInMemory(connString string) bool {
|
||||||
|
lc := strings.ToLower(connString)
|
||||||
|
|
||||||
|
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
|
||||||
|
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another way is to pass "mode=memory" in the "query string"
|
||||||
|
idx := strings.IndexRune(lc, '?')
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qs, _ := url.ParseQuery(lc[(idx + 1):])
|
||||||
|
|
||||||
|
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
|
||||||
|
// The default directory may not be writable when using a container with a read-only root file system
|
||||||
|
// See: https://www.sqlite.org/tempfiles.html
|
||||||
|
func ensureSqliteTempDir(dbPath string) error {
|
||||||
|
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
|
||||||
|
//
|
||||||
|
// - The SQLITE_TMPDIR environment variable
|
||||||
|
// - The TMPDIR environment variable
|
||||||
|
// - /var/tmp
|
||||||
|
// - /usr/tmp
|
||||||
|
// - /tmp
|
||||||
|
//
|
||||||
|
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
|
||||||
|
//
|
||||||
|
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
|
||||||
|
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
|
||||||
|
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
|
||||||
|
ok, err := utils.IsWritableDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
// We found a folder that's writable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
|
||||||
|
err := os.Setenv("SQLITE_TMPDIR", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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...)
|
||||||
}
|
}
|
||||||
|
|||||||
324
backend/internal/bootstrap/db_bootstrap_test.go
Normal file
324
backend/internal/bootstrap/db_bootstrap_test.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/url"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestIsSqliteInMemory(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
connStr string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "memory database with :memory:",
|
||||||
|
connStr: ":memory:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with file::memory:",
|
||||||
|
connStr: "file::memory:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with :MEMORY: (uppercase)",
|
||||||
|
connStr: ":MEMORY:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with FILE::MEMORY: (uppercase)",
|
||||||
|
connStr: "FILE::MEMORY:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with mixed case",
|
||||||
|
connStr: ":Memory:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has mode=memory",
|
||||||
|
connStr: "file:data?mode=memory",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file database",
|
||||||
|
connStr: "data.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file database with path",
|
||||||
|
connStr: "/path/to/data.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file database with file: prefix",
|
||||||
|
connStr: "file:data.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
connStr: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string containing memory but not at start",
|
||||||
|
connStr: "data:memory:.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has mode=ro",
|
||||||
|
connStr: "file:data?mode=ro",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isSqliteInMemory(tt.connStr)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertSqlitePragmaArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic file path",
|
||||||
|
input: "file:test.db",
|
||||||
|
expected: "file:test.db",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _busy_timeout to pragma",
|
||||||
|
input: "file:test.db?_busy_timeout=5000",
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _timeout to pragma",
|
||||||
|
input: "file:test.db?_timeout=5000",
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _foreign_keys to pragma",
|
||||||
|
input: "file:test.db?_foreign_keys=1",
|
||||||
|
expected: "file:test.db?_pragma=foreign_keys%281%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _fk to pragma",
|
||||||
|
input: "file:test.db?_fk=1",
|
||||||
|
expected: "file:test.db?_pragma=foreign_keys%281%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _synchronous to pragma",
|
||||||
|
input: "file:test.db?_synchronous=NORMAL",
|
||||||
|
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _sync to pragma",
|
||||||
|
input: "file:test.db?_sync=NORMAL",
|
||||||
|
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _auto_vacuum to pragma",
|
||||||
|
input: "file:test.db?_auto_vacuum=FULL",
|
||||||
|
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _vacuum to pragma",
|
||||||
|
input: "file:test.db?_vacuum=FULL",
|
||||||
|
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _case_sensitive_like to pragma",
|
||||||
|
input: "file:test.db?_case_sensitive_like=1",
|
||||||
|
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _cslike to pragma",
|
||||||
|
input: "file:test.db?_cslike=1",
|
||||||
|
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _locking_mode to pragma",
|
||||||
|
input: "file:test.db?_locking_mode=EXCLUSIVE",
|
||||||
|
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _locking to pragma",
|
||||||
|
input: "file:test.db?_locking=EXCLUSIVE",
|
||||||
|
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "converts _secure_delete to pragma",
|
||||||
|
input: "file:test.db?_secure_delete=1",
|
||||||
|
expected: "file:test.db?_pragma=secure_delete%281%29",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserves unrecognized parameters",
|
||||||
|
input: "file:test.db?mode=rw&cache=shared",
|
||||||
|
expected: "file:test.db?cache=shared&mode=rw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "handles multiple parameters",
|
||||||
|
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
|
||||||
|
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resultURL, _ := url.Parse(tt.input)
|
||||||
|
convertSqlitePragmaArgs(resultURL)
|
||||||
|
|
||||||
|
// Parse both URLs to compare components independently
|
||||||
|
expectedURL, err := url.Parse(tt.expected)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Compare scheme and path components
|
||||||
|
compareQueryStrings(t, expectedURL, resultURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddSqliteDefaultParameters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
isMemoryDB bool
|
||||||
|
expected string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "basic file database",
|
||||||
|
input: "file:test.db",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "in-memory database",
|
||||||
|
input: "file::memory:",
|
||||||
|
isMemoryDB: true,
|
||||||
|
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read-only database with mode=ro",
|
||||||
|
input: "file:test.db?mode=ro",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "immutable database",
|
||||||
|
input: "file:test.db?immutable=1",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with existing _txlock",
|
||||||
|
input: "file:test.db?_txlock=deferred",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with existing busy_timeout pragma",
|
||||||
|
input: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with existing journal_mode pragma",
|
||||||
|
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with forbidden foreign_keys pragma",
|
||||||
|
input: "file:test.db?_pragma=foreign_keys%280%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with multiple existing pragmas",
|
||||||
|
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with mode=rw (not read-only)",
|
||||||
|
input: "file:test.db?mode=rw",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with immutable=0 (not immutable)",
|
||||||
|
input: "file:test.db?immutable=0",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with mixed case mode=RO",
|
||||||
|
input: "file:test.db?mode=RO",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with mixed case immutable=1",
|
||||||
|
input: "file:test.db?immutable=1",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex database configuration",
|
||||||
|
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resultURL, err := url.Parse(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
expectedURL, err := url.Parse(tt.expected)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
compareQueryStrings(t, expectedURL, resultURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Compare scheme and path components
|
||||||
|
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
|
||||||
|
assert.Equal(t, expectedURL.Path, resultURL.Path)
|
||||||
|
|
||||||
|
// Compare query parameters regardless of order
|
||||||
|
expectedQuery := expectedURL.Query()
|
||||||
|
resultQuery := resultURL.Query()
|
||||||
|
|
||||||
|
assert.Len(t, expectedQuery, len(resultQuery))
|
||||||
|
|
||||||
|
for key, expectedValues := range expectedQuery {
|
||||||
|
resultValues, ok := resultQuery[key]
|
||||||
|
_ = assert.True(t, ok) &&
|
||||||
|
assert.ElementsMatch(t, expectedValues, resultValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
30
backend/internal/bootstrap/e2etest_router_bootstrap.go
Normal file
30
backend/internal/bootstrap/e2etest_router_bootstrap.go
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
//go:build e2etest
|
||||||
|
|
||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
// When building for E2E tests, add the e2etest controller
|
||||||
|
func init() {
|
||||||
|
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)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||||
|
os.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
controller.NewTestController(apiGroup, testService)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,133 +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)
|
|
||||||
}
|
|
||||||
key.Set(jwk.KeyIDKey, keyId)
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, keyID)
|
|
||||||
|
|
||||||
// Check algorithm is set
|
|
||||||
var alg jwa.SignatureAlgorithm
|
|
||||||
err = key.Get(jwk.AlgorithmKey, &alg)
|
|
||||||
assert.NoError(t, err)
|
|
||||||
assert.NotEmpty(t, alg)
|
|
||||||
|
|
||||||
// Check key usage is set
|
|
||||||
var keyUsage string
|
|
||||||
err = key.Get(jwk.KeyUsageKey, &keyUsage)
|
|
||||||
assert.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"))
|
|
||||||
assert.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)
|
|
||||||
assert.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
|
|
||||||
}
|
|
||||||
203
backend/internal/bootstrap/observability_boostrap.go
Normal file
203
backend/internal/bootstrap/observability_boostrap.go
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
sloggin "github.com/gin-contrib/slog"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||||
|
"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"
|
||||||
|
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
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, metrics, traces bool) (shutdownFns []utils.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([]utils.Service, 0, 2)
|
||||||
|
|
||||||
|
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
|
||||||
|
err = initOtelLogging(ctx, resource)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tracing
|
||||||
|
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
} else if tracingShutdownFn != nil {
|
||||||
|
shutdownFns = append(shutdownFns, tracingShutdownFn)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metrics
|
||||||
|
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, 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) 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 fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
|
||||||
|
|
||||||
|
// Create the 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()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the logger provider
|
||||||
|
provider := sdklog.NewLoggerProvider(
|
||||||
|
sdklog.WithProcessor(
|
||||||
|
sdklog.NewBatchProcessor(exp),
|
||||||
|
),
|
||||||
|
sdklog.WithResource(resource),
|
||||||
|
)
|
||||||
|
|
||||||
|
// Set the logger provider globally
|
||||||
|
globallog.SetLoggerProvider(provider)
|
||||||
|
|
||||||
|
// Wrap the handler in a "fanout" one
|
||||||
|
handler = utils.LogFanoutHandler{
|
||||||
|
handler,
|
||||||
|
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
|
||||||
|
if !traces {
|
||||||
|
otel.SetTracerProvider(tracenoop.NewTracerProvider())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
|
||||||
|
if !metrics {
|
||||||
|
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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,22 +1,44 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
sloggin "github.com/gin-contrib/slog"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||||
"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/utils/systemd"
|
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"gorm.io/gorm"
|
"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/middleware"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
// This is used to register additional controllers for tests
|
||||||
|
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
|
||||||
|
|
||||||
|
func initRouter(db *gorm.DB, svc *services) utils.Service {
|
||||||
|
runner, err := initRouterInternal(db, svc)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to init router", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
return runner
|
||||||
|
}
|
||||||
|
|
||||||
|
func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||||
// Set the appropriate Gin mode based on the environment
|
// Set the appropriate Gin mode based on the environment
|
||||||
switch common.EnvConfig.AppEnv {
|
switch common.EnvConfig.AppEnv {
|
||||||
case "production":
|
case "production":
|
||||||
@@ -27,75 +49,160 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.New()
|
||||||
r.Use(gin.Logger())
|
initLogger(r)
|
||||||
|
|
||||||
// Initialize services
|
if !common.EnvConfig.TrustProxy {
|
||||||
emailService, err := service.NewEmailService(appConfigService, db)
|
_ = r.SetTrustedProxies(nil)
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Unable to create email service: %s", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
geoLiteService := service.NewGeoLiteService()
|
if common.EnvConfig.TracingEnabled {
|
||||||
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
|
r.Use(otelgin.Middleware(common.Name))
|
||||||
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)
|
|
||||||
testService := service.NewTestService(db, appConfigService, jwtService)
|
|
||||||
userGroupService := service.NewUserGroupService(db, appConfigService)
|
|
||||||
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
|
|
||||||
apiKeyService := service.NewApiKeyService(db)
|
|
||||||
|
|
||||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
|
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
|
||||||
|
|
||||||
// Setup global middleware
|
// Setup global middleware
|
||||||
r.Use(middleware.NewCorsMiddleware().Add())
|
r.Use(middleware.NewCorsMiddleware().Add())
|
||||||
|
r.Use(middleware.NewCspMiddleware().Add())
|
||||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||||
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
|
|
||||||
|
|
||||||
job.RegisterLdapJobs(ldapService, appConfigService)
|
err := frontend.RegisterFrontend(r)
|
||||||
job.RegisterDbCleanupJobs(db)
|
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||||
|
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||||
|
} else if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize middleware for specific routes
|
// Initialize middleware for specific routes
|
||||||
authMiddleware := middleware.NewAuthMiddleware(apiKeyService, jwtService)
|
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
|
||||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||||
|
|
||||||
// Set up API routes
|
// Set up API routes
|
||||||
apiGroup := r.Group("/api")
|
apiGroup := r.Group("/api", rateLimitMiddleware)
|
||||||
controller.NewApiKeyController(apiGroup, authMiddleware, apiKeyService)
|
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
||||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
|
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
||||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
|
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
||||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
|
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
|
||||||
controller.NewAppConfigController(apiGroup, authMiddleware, appConfigService, emailService, ldapService)
|
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||||
controller.NewAuditLogController(apiGroup, auditLogService, authMiddleware)
|
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||||
controller.NewUserGroupController(apiGroup, authMiddleware, userGroupService)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, customClaimService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||||
|
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||||
|
controller.NewVersionController(apiGroup, svc.versionService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
controller.NewTestController(apiGroup, testService)
|
for _, f := range registerTestControllers {
|
||||||
|
f(apiGroup, db, svc)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up base routes
|
// Set up base routes
|
||||||
baseGroup := r.Group("/")
|
baseGroup := r.Group("/", rateLimitMiddleware)
|
||||||
controller.NewWellKnownController(baseGroup, jwtService)
|
controller.NewWellKnownController(baseGroup, svc.jwtService)
|
||||||
|
|
||||||
// Get the listener
|
// Set up healthcheck routes
|
||||||
l, err := net.Listen("tcp", common.EnvConfig.Host+":"+common.EnvConfig.Port)
|
// These are not rate-limited
|
||||||
|
controller.NewHealthzController(r)
|
||||||
|
|
||||||
|
// Set up the server
|
||||||
|
srv := &http.Server{
|
||||||
|
MaxHeaderBytes: 1 << 20,
|
||||||
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
|
Handler: r,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up the listener
|
||||||
|
network := "tcp"
|
||||||
|
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
|
||||||
|
if common.EnvConfig.UnixSocket != "" {
|
||||||
|
network = "unix"
|
||||||
|
addr = common.EnvConfig.UnixSocket
|
||||||
|
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal(err)
|
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify systemd that we are ready
|
// Set the socket mode if using a Unix socket
|
||||||
if err := systemd.SdNotifyReady(); err != nil {
|
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
|
||||||
log.Println("Unable to notify systemd that the service is ready: ", err)
|
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||||
// continue to serve anyway since it's not that important
|
if err != nil {
|
||||||
|
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 {
|
||||||
|
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Serve requests
|
// Service runner function
|
||||||
if err := r.RunListener(l); err != nil {
|
runFn := func(ctx context.Context) error {
|
||||||
log.Fatal(err)
|
slog.Info("Server listening", slog.String("addr", addr))
|
||||||
|
|
||||||
|
// Start the server in a background goroutine
|
||||||
|
go func() {
|
||||||
|
defer listener.Close()
|
||||||
|
|
||||||
|
// Next call blocks until the server is shut down
|
||||||
|
srvErr := srv.Serve(listener)
|
||||||
|
if srvErr != http.ErrServerClosed {
|
||||||
|
slog.Error("Error starting app server", "error", srvErr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Notify systemd that we are ready
|
||||||
|
err = systemd.SdNotifyReady()
|
||||||
|
if err != nil {
|
||||||
|
// Log the error only
|
||||||
|
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block until the context is canceled
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
// Handle graceful shutdown
|
||||||
|
// 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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return runFn, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func initLogger(r *gin.Engine) {
|
||||||
|
loggerSkipPathsPrefix := []string{
|
||||||
|
"GET /api/application-images/logo",
|
||||||
|
"GET /api/application-images/background",
|
||||||
|
"GET /api/application-images/favicon",
|
||||||
|
"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
|
||||||
|
}),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|||||||
40
backend/internal/bootstrap/scheduler_bootstrap.go
Normal file
40
backend/internal/bootstrap/scheduler_bootstrap.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||||
|
)
|
||||||
|
|
||||||
|
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, 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.RegisterFileCleanupJobs(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err)
|
||||||
|
}
|
||||||
|
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyService, svc.appConfigService)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
|
||||||
|
}
|
||||||
|
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
72
backend/internal/bootstrap/services_bootstrap.go
Normal file
72
backend/internal/bootstrap/services_bootstrap.go
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
package bootstrap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type services struct {
|
||||||
|
appConfigService *service.AppConfigService
|
||||||
|
appImagesService *service.AppImagesService
|
||||||
|
emailService *service.EmailService
|
||||||
|
geoLiteService *service.GeoLiteService
|
||||||
|
auditLogService *service.AuditLogService
|
||||||
|
jwtService *service.JwtService
|
||||||
|
webauthnService *service.WebAuthnService
|
||||||
|
userService *service.UserService
|
||||||
|
customClaimService *service.CustomClaimService
|
||||||
|
oidcService *service.OidcService
|
||||||
|
userGroupService *service.UserGroupService
|
||||||
|
ldapService *service.LdapService
|
||||||
|
apiKeyService *service.ApiKeyService
|
||||||
|
versionService *service.VersionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initializes all services
|
||||||
|
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string) (svc *services, err error) {
|
||||||
|
svc = &services{}
|
||||||
|
|
||||||
|
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.appImagesService = service.NewAppImagesService(imageExtensions)
|
||||||
|
|
||||||
|
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(db, svc.appConfigService)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.customClaimService = service.NewCustomClaimService(db)
|
||||||
|
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
||||||
|
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
|
||||||
|
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||||
|
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||||
|
|
||||||
|
svc.versionService = service.NewVersionService(httpClient)
|
||||||
|
|
||||||
|
return svc, nil
|
||||||
|
}
|
||||||
83
backend/internal/cmds/healthcheck.go
Normal file
83
backend/internal/cmds/healthcheck.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type healthcheckFlags struct {
|
||||||
|
Endpoint string
|
||||||
|
Verbose bool
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
url := flags.Endpoint + "/healthz"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"Failed to create request object",
|
||||||
|
"error", err,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"Failed to perform request",
|
||||||
|
"error", err,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"Healthcheck failed",
|
||||||
|
"status", res.StatusCode,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.Verbose {
|
||||||
|
slog.InfoContext(ctx,
|
||||||
|
"Healthcheck succeeded",
|
||||||
|
"status", res.StatusCode,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
|
||||||
|
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(healthcheckCmd)
|
||||||
|
}
|
||||||
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"
|
||||||
|
"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/service"
|
||||||
|
"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()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyRotate(cmd.Context(), flags, db, &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, 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")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init the services we need
|
||||||
|
appConfigService, err := service.NewAppConfigService(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create app config service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the key provider
|
||||||
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
|
||||||
|
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(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
|
||||||
|
}
|
||||||
216
backend/internal/cmds/key_rotate_test.go
Normal file
216
backend/internal/cmds/key_rotate_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"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/service"
|
||||||
|
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) {
|
||||||
|
t.Run("file storage", func(t *testing.T) {
|
||||||
|
testKeyRotateWithFileStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("database storage", func(t *testing.T) {
|
||||||
|
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||||
|
// Create temporary directory for keys
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
keysPath := filepath.Join(tempDir, "keys")
|
||||||
|
err := os.MkdirAll(keysPath, 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up file storage config
|
||||||
|
envConfig := &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: keysPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test database
|
||||||
|
db := testingutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
|
// Initialize app config service and create instance
|
||||||
|
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||||
|
|
||||||
|
// Check if key exists before rotation
|
||||||
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run the key rotation
|
||||||
|
err = keyRotate(t.Context(), flags, db, 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()
|
||||||
|
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 testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||||
|
// Set up database storage config
|
||||||
|
envConfig := &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "database",
|
||||||
|
EncryptionKey: []byte("test-encryption-key-characters-long"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test database
|
||||||
|
db := testingutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
|
// Initialize app config service and create instance
|
||||||
|
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||||
|
|
||||||
|
// Get key provider
|
||||||
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run the key rotation
|
||||||
|
err = keyRotate(t.Context(), flags, db, 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()
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
}
|
||||||
36
backend/internal/cmds/root.go
Normal file
36
backend/internal/cmds/root.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
||||||
|
)
|
||||||
|
|
||||||
|
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.",
|
||||||
|
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)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,77 +1,277 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
sloggin "github.com/gin-contrib/slog"
|
||||||
_ "github.com/joho/godotenv/autoload"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
)
|
)
|
||||||
|
|
||||||
type DbProvider string
|
type DbProvider string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DbProviderSqlite DbProvider = "sqlite"
|
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
|
||||||
DbProviderPostgres DbProvider = "postgres"
|
TracerName = "github.com/pocket-id/pocket-id/backend/tracing"
|
||||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
// MeterName should be passed to otel.Meter when create custom metrics.
|
||||||
|
MeterName = "github.com/pocket-id/pocket-id/backend/metrics"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
defaultSqliteConnString string = "data/pocket-id.db"
|
||||||
|
AppUrl string = "http://localhost:1411"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
AppEnv string `env:"APP_ENV"`
|
AppEnv string `env:"APP_ENV" options:"toLower"`
|
||||||
AppURL string `env:"PUBLIC_APP_URL"`
|
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
AppURL string `env:"APP_URL" options:"toLower"`
|
||||||
SqliteDBPath string `env:"SQLITE_DB_PATH"`
|
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
|
||||||
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
|
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
KeysPath string `env:"KEYS_PATH"`
|
KeysPath string `env:"KEYS_PATH"`
|
||||||
Port string `env:"BACKEND_PORT"`
|
KeysStorage string `env:"KEYS_STORAGE"`
|
||||||
Host string `env:"HOST"`
|
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
Port string `env:"PORT"`
|
||||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
Host string `env:"HOST" options:"toLower"`
|
||||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
UnixSocket string `env:"UNIX_SOCKET"`
|
||||||
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||||
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||||
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||||
|
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||||
|
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||||
|
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||||
|
LogJSON bool `env:"LOG_JSON"`
|
||||||
|
TrustProxy bool `env:"TRUST_PROXY"`
|
||||||
|
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||||
|
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||||
|
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = defaultConfig()
|
||||||
AppEnv: "production",
|
|
||||||
DbProvider: "sqlite",
|
|
||||||
SqliteDBPath: "data/pocket-id.db",
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
err := parseEnvConfig()
|
||||||
log.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the environment variables
|
|
||||||
switch EnvConfig.DbProvider {
|
|
||||||
case DbProviderSqlite:
|
|
||||||
if EnvConfig.SqliteDBPath == "" {
|
|
||||||
log.Fatal("Missing SQLITE_DB_PATH environment variable")
|
|
||||||
}
|
|
||||||
case DbProviderPostgres:
|
|
||||||
if EnvConfig.PostgresConnectionString == "" {
|
|
||||||
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
|
||||||
}
|
|
||||||
|
|
||||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("PUBLIC_APP_URL is not a valid URL")
|
slog.Error("Configuration error", slog.Any("error", err))
|
||||||
}
|
os.Exit(1)
|
||||||
if parsedAppUrl.Path != "" {
|
|
||||||
log.Fatal("PUBLIC_APP_URL must not contain a path")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func defaultConfig() EnvConfigSchema {
|
||||||
|
return EnvConfigSchema{
|
||||||
|
AppEnv: "production",
|
||||||
|
LogLevel: "info",
|
||||||
|
DbProvider: "sqlite",
|
||||||
|
DbConnectionString: "",
|
||||||
|
UploadPath: "data/uploads",
|
||||||
|
KeysPath: "data/keys",
|
||||||
|
KeysStorage: "", // "database" or "file"
|
||||||
|
EncryptionKey: nil,
|
||||||
|
AppURL: AppUrl,
|
||||||
|
Port: "1411",
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
UnixSocket: "",
|
||||||
|
UnixSocketMode: "",
|
||||||
|
MaxMindLicenseKey: "",
|
||||||
|
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||||
|
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||||
|
LocalIPv6Ranges: "",
|
||||||
|
UiConfigDisabled: false,
|
||||||
|
MetricsEnabled: false,
|
||||||
|
TracingEnabled: false,
|
||||||
|
TrustProxy: false,
|
||||||
|
AnalyticsDisabled: false,
|
||||||
|
AllowDowngrade: false,
|
||||||
|
InternalAppURL: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnvConfig() error {
|
||||||
|
parsers := map[reflect.Type]env.ParserFunc{
|
||||||
|
reflect.TypeOf([]byte{}): func(value string) (interface{}, 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = validateEnvConfig(&EnvConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateEnvConfig checks the EnvConfig for required fields and valid values
|
||||||
|
func validateEnvConfig(config *EnvConfigSchema) error {
|
||||||
|
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
|
||||||
|
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch config.DbProvider {
|
||||||
|
case DbProviderSqlite:
|
||||||
|
if config.DbConnectionString == "" {
|
||||||
|
config.DbConnectionString = defaultSqliteConnString
|
||||||
|
}
|
||||||
|
case DbProviderPostgres:
|
||||||
|
if config.DbConnectionString == "" {
|
||||||
|
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||||
|
}
|
||||||
|
|
||||||
|
parsedAppUrl, err := url.Parse(config.AppURL)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("APP_URL is not a valid URL")
|
||||||
|
}
|
||||||
|
if parsedAppUrl.Path != "" {
|
||||||
|
return errors.New("APP_URL must not contain a path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
||||||
|
if config.InternalAppURL == "" {
|
||||||
|
config.InternalAppURL = config.AppURL
|
||||||
|
} else {
|
||||||
|
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New("INTERNAL_APP_URL is not a valid URL")
|
||||||
|
}
|
||||||
|
if parsedInternalAppUrl.Path != "" {
|
||||||
|
return errors.New("INTERNAL_APP_URL must not contain a path")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch config.KeysStorage {
|
||||||
|
// KeysStorage defaults to "file" if empty
|
||||||
|
case "":
|
||||||
|
config.KeysStorage = "file"
|
||||||
|
case "database":
|
||||||
|
if config.EncryptionKey == nil {
|
||||||
|
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||||
|
}
|
||||||
|
case "file":
|
||||||
|
// All good, these are valid values
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate LOCAL_IPV6_RANGES
|
||||||
|
ranges := strings.Split(config.LocalIPv6Ranges, ",")
|
||||||
|
for _, rangeStr := range ranges {
|
||||||
|
rangeStr = strings.TrimSpace(rangeStr)
|
||||||
|
if rangeStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, 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
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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.Split(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
258
backend/internal/common/env_config_test.go
Normal file
258
backend/internal/common/env_config_test.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
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_PROVIDER", "SQLITE") // should be lowercased automatically
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
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_PROVIDER", "POSTGRES")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
||||||
|
t.Setenv("APP_URL", "https://example.com")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "invalid")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "test")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "invalid DB_PROVIDER value")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail when Postgres DB_CONNECTION_STRING is missing", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "postgres")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "missing required env var 'DB_CONNECTION_STRING' for Postgres")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "€://not-a-valid-url")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
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_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000/path")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
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_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
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_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should default KEYS_STORAGE to 'file' when empty", 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, "file", EnvConfig.KeysStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", 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("KEYS_STORAGE", "database")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
|
||||||
|
validStorageTypes := []string{"file", "database"}
|
||||||
|
|
||||||
|
for _, storage := range validStorageTypes {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
t.Setenv("KEYS_STORAGE", storage)
|
||||||
|
if storage == "database" {
|
||||||
|
t.Setenv("ENCRYPTION_KEY", "test-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, storage, EnvConfig.KeysStorage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid KEYS_STORAGE value", 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("KEYS_STORAGE", "invalid")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should parse boolean environment variables correctly", 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("UI_CONFIG_DISABLED", "true")
|
||||||
|
t.Setenv("METRICS_ENABLED", "true")
|
||||||
|
t.Setenv("TRACING_ENABLED", "false")
|
||||||
|
t.Setenv("TRUST_PROXY", "true")
|
||||||
|
t.Setenv("ANALYTICS_DISABLED", "false")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, EnvConfig.UiConfigDisabled)
|
||||||
|
assert.True(t, EnvConfig.MetricsEnabled)
|
||||||
|
assert.False(t, EnvConfig.TracingEnabled)
|
||||||
|
assert.True(t, EnvConfig.TrustProxy)
|
||||||
|
assert.False(t, EnvConfig.AnalyticsDisabled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should parse string environment variables correctly", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "postgres")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
||||||
|
t.Setenv("APP_URL", "https://prod.example.com")
|
||||||
|
t.Setenv("APP_ENV", "STAGING")
|
||||||
|
t.Setenv("UPLOAD_PATH", "/custom/uploads")
|
||||||
|
t.Setenv("KEYS_PATH", "/custom/keys")
|
||||||
|
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")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
|
||||||
|
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
|
||||||
|
assert.Equal(t, "8080", EnvConfig.Port)
|
||||||
|
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
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, "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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
)
|
)
|
||||||
@@ -17,10 +18,16 @@ type AlreadyInUseError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e *AlreadyInUseError) Error() string {
|
func (e *AlreadyInUseError) Error() string {
|
||||||
return fmt.Sprintf("%s is already in use", e.Property)
|
return e.Property + " is already in use"
|
||||||
}
|
}
|
||||||
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
type SetupAlreadyCompletedError struct{}
|
type SetupAlreadyCompletedError struct{}
|
||||||
|
|
||||||
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||||
@@ -58,11 +65,23 @@ type OidcClientSecretInvalidError struct{}
|
|||||||
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcClientAssertionInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
|
||||||
|
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type OidcInvalidAuthorizationCodeError struct{}
|
type OidcInvalidAuthorizationCodeError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingCallbackURLError) Error() string {
|
||||||
|
return "unable to detect callback url, it might be necessary for an admin to fix this"
|
||||||
|
}
|
||||||
|
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type OidcInvalidCallbackURLError struct{}
|
type OidcInvalidCallbackURLError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidCallbackURLError) Error() string {
|
func (e *OidcInvalidCallbackURLError) Error() string {
|
||||||
@@ -75,11 +94,6 @@ type FileTypeNotSupportedError struct{}
|
|||||||
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||||
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
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 }
|
|
||||||
|
|
||||||
type FileTooLargeError struct {
|
type FileTooLargeError struct {
|
||||||
MaxSize string
|
MaxSize string
|
||||||
}
|
}
|
||||||
@@ -154,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
|
|||||||
}
|
}
|
||||||
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
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{}
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidCodeVerifierError) Error() string {
|
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||||
@@ -222,8 +229,7 @@ type InvalidUUIDError struct{}
|
|||||||
func (e *InvalidUUIDError) Error() string {
|
func (e *InvalidUUIDError) Error() string {
|
||||||
return "Invalid UUID"
|
return "Invalid UUID"
|
||||||
}
|
}
|
||||||
|
func (e *InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
type InvalidEmailError struct{}
|
|
||||||
|
|
||||||
type OneTimeAccessDisabledError struct{}
|
type OneTimeAccessDisabledError struct{}
|
||||||
|
|
||||||
@@ -237,31 +243,34 @@ type InvalidAPIKeyError struct{}
|
|||||||
func (e *InvalidAPIKeyError) Error() string {
|
func (e *InvalidAPIKeyError) Error() string {
|
||||||
return "Invalid Api Key"
|
return "Invalid Api Key"
|
||||||
}
|
}
|
||||||
|
func (e *InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type NoAPIKeyProvidedError struct{}
|
type NoAPIKeyProvidedError struct{}
|
||||||
|
|
||||||
func (e *NoAPIKeyProvidedError) Error() string {
|
func (e *NoAPIKeyProvidedError) Error() string {
|
||||||
return "No API Key Provided"
|
return "No API Key Provided"
|
||||||
}
|
}
|
||||||
|
func (e *NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type APIKeyNotFoundError struct{}
|
type APIKeyNotFoundError struct{}
|
||||||
|
|
||||||
func (e *APIKeyNotFoundError) Error() string {
|
func (e *APIKeyNotFoundError) Error() string {
|
||||||
return "API Key Not Found"
|
return "API Key Not Found"
|
||||||
}
|
}
|
||||||
|
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||||
|
|
||||||
type APIKeyExpirationDateError struct{}
|
type APIKeyExpirationDateError struct{}
|
||||||
|
|
||||||
func (e *APIKeyExpirationDateError) Error() string {
|
func (e *APIKeyExpirationDateError) Error() string {
|
||||||
return "API Key expiration time must be in the future"
|
return "API Key expiration time must be in the future"
|
||||||
}
|
}
|
||||||
|
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type OidcInvalidRefreshTokenError struct{}
|
type OidcInvalidRefreshTokenError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidRefreshTokenError) Error() string {
|
func (e *OidcInvalidRefreshTokenError) Error() string {
|
||||||
return "refresh token is invalid or expired"
|
return "refresh token is invalid or expired"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
|
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
@@ -271,7 +280,6 @@ type OidcMissingRefreshTokenError struct{}
|
|||||||
func (e *OidcMissingRefreshTokenError) Error() string {
|
func (e *OidcMissingRefreshTokenError) Error() string {
|
||||||
return "refresh token is required"
|
return "refresh token is required"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
|
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
@@ -281,7 +289,102 @@ type OidcMissingAuthorizationCodeError struct{}
|
|||||||
func (e *OidcMissingAuthorizationCodeError) Error() string {
|
func (e *OidcMissingAuthorizationCodeError) Error() string {
|
||||||
return "authorization code is required"
|
return "authorization code is required"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
|
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
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 OidcSlowDownError struct{}
|
||||||
|
|
||||||
|
func (e *OidcSlowDownError) Error() string {
|
||||||
|
return "polling too frequently"
|
||||||
|
}
|
||||||
|
func (e *OidcSlowDownError) HttpStatusCode() int {
|
||||||
|
return http.StatusTooManyRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcAuthorizationPendingError struct{}
|
||||||
|
|
||||||
|
func (e *OidcAuthorizationPendingError) Error() string {
|
||||||
|
return "authorization is still pending"
|
||||||
|
}
|
||||||
|
func (e *OidcAuthorizationPendingError) 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
|
||||||
|
}
|
||||||
|
|||||||
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"
|
||||||
@@ -38,10 +38,10 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
|||||||
// @Summary List API keys
|
// @Summary List API keys
|
||||||
// @Description Get a paginated list of API keys belonging to the current user
|
// @Description Get a paginated list of API keys belonging to the current user
|
||||||
// @Tags API Keys
|
// @Tags API Keys
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||||
// @Router /api/api-keys [get]
|
// @Router /api/api-keys [get]
|
||||||
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||||
@@ -49,19 +49,19 @@ func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
|||||||
|
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(userID, sortedPaginationRequest)
|
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiKeysDto []dto.ApiKeyDto
|
var apiKeysDto []dto.ApiKeyDto
|
||||||
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,20 +82,20 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
|||||||
userID := ctx.GetString("userID")
|
userID := ctx.GetString("userID")
|
||||||
|
|
||||||
var input dto.ApiKeyCreateDto
|
var input dto.ApiKeyCreateDto
|
||||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
apiKey, token, err := c.apiKeyService.CreateApiKey(userID, input)
|
apiKey, token, err := c.apiKeyService.CreateApiKey(ctx.Request.Context(), userID, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var apiKeyDto dto.ApiKeyDto
|
var apiKeyDto dto.ApiKeyDto
|
||||||
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
|
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,8 +116,8 @@ func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
|
|||||||
userID := ctx.GetString("userID")
|
userID := ctx.GetString("userID")
|
||||||
apiKeyID := ctx.Param("id")
|
apiKeyID := ctx.Param("id")
|
||||||
|
|
||||||
if err := c.apiKeyService.RevokeApiKey(userID, apiKeyID); err != nil {
|
if err := c.apiKeyService.RevokeApiKey(ctx.Request.Context(), userID, apiKeyID); err != nil {
|
||||||
ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"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/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewAppConfigController creates a new controller for application configuration endpoints
|
// NewAppConfigController creates a new controller for application configuration endpoints
|
||||||
@@ -33,13 +32,6 @@ func NewAppConfigController(
|
|||||||
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
|
||||||
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
|
||||||
|
|
||||||
group.GET("/application-configuration/logo", acc.getLogoHandler)
|
|
||||||
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
|
|
||||||
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
|
|
||||||
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
|
|
||||||
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
|
|
||||||
|
|
||||||
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
|
||||||
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
|
||||||
}
|
}
|
||||||
@@ -57,22 +49,24 @@ type AppConfigController struct {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||||
// @Failure 500 {object} object "{"error": "error message"}"
|
|
||||||
// @Router /application-configuration [get]
|
// @Router /application-configuration [get]
|
||||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||||
configuration, err := acc.appConfigService.ListAppConfig(false)
|
configuration := acc.appConfigService.ListAppConfig(false)
|
||||||
if err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var configVariablesDto []dto.PublicAppConfigVariableDto
|
var configVariablesDto []dto.PublicAppConfigVariableDto
|
||||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, configVariablesDto)
|
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
|
||||||
|
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
|
||||||
|
Key: "uiConfigDisabled",
|
||||||
|
Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled),
|
||||||
|
Type: "boolean",
|
||||||
|
})
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listAllAppConfigHandler godoc
|
// listAllAppConfigHandler godoc
|
||||||
@@ -82,22 +76,17 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} dto.AppConfigVariableDto
|
// @Success 200 {array} dto.AppConfigVariableDto
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /application-configuration/all [get]
|
// @Router /application-configuration/all [get]
|
||||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||||
configuration, err := acc.appConfigService.ListAppConfig(true)
|
configuration := acc.appConfigService.ListAppConfig(true)
|
||||||
if err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var configVariablesDto []dto.AppConfigVariableDto
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(200, configVariablesDto)
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateAppConfigHandler godoc
|
// updateAppConfigHandler godoc
|
||||||
@@ -108,185 +97,39 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||||
// @Success 200 {array} dto.AppConfigVariableDto
|
// @Success 200 {array} dto.AppConfigVariableDto
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration [put]
|
// @Router /api/application-configuration [put]
|
||||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||||
var input dto.AppConfigUpdateDto
|
var input dto.AppConfigUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
|
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var configVariablesDto []dto.AppConfigVariableDto
|
var configVariablesDto []dto.AppConfigVariableDto
|
||||||
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, configVariablesDto)
|
c.JSON(http.StatusOK, configVariablesDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getLogoHandler godoc
|
|
||||||
// @Summary Get logo image
|
|
||||||
// @Description Get the logo image for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Produce image/svg+xml
|
|
||||||
// @Success 200 {file} binary "Logo image"
|
|
||||||
// @Router /api/application-configuration/logo [get]
|
|
||||||
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
|
||||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
|
||||||
|
|
||||||
var imageName string
|
|
||||||
var imageType string
|
|
||||||
|
|
||||||
if lightLogo {
|
|
||||||
imageName = "logoLight"
|
|
||||||
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
|
|
||||||
} else {
|
|
||||||
imageName = "logoDark"
|
|
||||||
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.getImage(c, imageName, imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getFaviconHandler godoc
|
|
||||||
// @Summary Get favicon
|
|
||||||
// @Description Get the favicon for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Produce image/x-icon
|
|
||||||
// @Success 200 {file} binary "Favicon image"
|
|
||||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
|
||||||
// @Router /api/application-configuration/favicon [get]
|
|
||||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
|
||||||
acc.getImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
// getBackgroundImageHandler godoc
|
|
||||||
// @Summary Get background image
|
|
||||||
// @Description Get the background image for the application
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Produce image/png
|
|
||||||
// @Produce image/jpeg
|
|
||||||
// @Success 200 {file} binary "Background image"
|
|
||||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
|
||||||
// @Router /api/application-configuration/background-image [get]
|
|
||||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
|
||||||
acc.getImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateLogoHandler godoc
|
|
||||||
// @Summary Update logo
|
|
||||||
// @Description Update the application logo
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
|
||||||
// @Param file formData file true "Logo image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/logo [put]
|
|
||||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|
||||||
lightLogo := c.DefaultQuery("light", "true") == "true"
|
|
||||||
|
|
||||||
var imageName string
|
|
||||||
var imageType string
|
|
||||||
|
|
||||||
if lightLogo {
|
|
||||||
imageName = "logoLight"
|
|
||||||
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
|
|
||||||
} else {
|
|
||||||
imageName = "logoDark"
|
|
||||||
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
|
|
||||||
}
|
|
||||||
|
|
||||||
acc.updateImage(c, imageName, imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateFaviconHandler godoc
|
|
||||||
// @Summary Update favicon
|
|
||||||
// @Description Update the application favicon
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Favicon file (.ico)"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/favicon [put]
|
|
||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
fileType := utils.GetFileExtension(file.Filename)
|
|
||||||
if fileType != "ico" {
|
|
||||||
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
acc.updateImage(c, "favicon", "ico")
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateBackgroundImageHandler godoc
|
|
||||||
// @Summary Update background image
|
|
||||||
// @Description Update the application background image
|
|
||||||
// @Tags Application Configuration
|
|
||||||
// @Accept multipart/form-data
|
|
||||||
// @Param file formData file true "Background image file"
|
|
||||||
// @Success 204 "No Content"
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/background-image [put]
|
|
||||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
|
||||||
imageType := acc.appConfigService.DbConfig.BackgroundImageType.Value
|
|
||||||
acc.updateImage(c, "background", imageType)
|
|
||||||
}
|
|
||||||
|
|
||||||
// getImage is a helper function to serve image files
|
|
||||||
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
|
|
||||||
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
|
|
||||||
mimeType := utils.GetImageMimeType(imageType)
|
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
|
||||||
c.File(imagePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateImage is a helper function to update image files
|
|
||||||
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
|
|
||||||
file, err := c.FormFile("file")
|
|
||||||
if err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
|
|
||||||
if err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
|
|
||||||
// syncLdapHandler godoc
|
// syncLdapHandler godoc
|
||||||
// @Summary Synchronize LDAP
|
// @Summary Synchronize LDAP
|
||||||
// @Description Manually trigger LDAP synchronization
|
// @Description Manually trigger LDAP synchronization
|
||||||
// @Tags Application Configuration
|
// @Tags Application Configuration
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/sync-ldap [post]
|
// @Router /api/application-configuration/sync-ldap [post]
|
||||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||||
err := acc.ldapService.SyncAll()
|
err := acc.ldapService.SyncAll(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -298,14 +141,13 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
|||||||
// @Description Send a test email to verify email configuration
|
// @Description Send a test email to verify email configuration
|
||||||
// @Tags Application Configuration
|
// @Tags Application Configuration
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/test-email [post]
|
// @Router /api/application-configuration/test-email [post]
|
||||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
err := acc.emailService.SendTestEmail(userID)
|
err := acc.emailService.SendTestEmail(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
173
backend/internal/controller/app_images_controller.go
Normal file
173
backend/internal/controller/app_images_controller.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"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/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewAppImagesController(
|
||||||
|
group *gin.RouterGroup,
|
||||||
|
authMiddleware *middleware.AuthMiddleware,
|
||||||
|
appImagesService *service.AppImagesService,
|
||||||
|
) {
|
||||||
|
controller := &AppImagesController{
|
||||||
|
appImagesService: appImagesService,
|
||||||
|
}
|
||||||
|
|
||||||
|
group.GET("/application-images/logo", controller.getLogoHandler)
|
||||||
|
group.GET("/application-images/background", controller.getBackgroundImageHandler)
|
||||||
|
group.GET("/application-images/favicon", controller.getFaviconHandler)
|
||||||
|
|
||||||
|
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
|
||||||
|
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
|
||||||
|
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AppImagesController struct {
|
||||||
|
appImagesService *service.AppImagesService
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLogoHandler godoc
|
||||||
|
// @Summary Get logo image
|
||||||
|
// @Description Get the logo image for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Produce image/svg+xml
|
||||||
|
// @Success 200 {file} binary "Logo image"
|
||||||
|
// @Router /api/application-images/logo [get]
|
||||||
|
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
|
||||||
|
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||||
|
imageName := "logoLight"
|
||||||
|
if !lightLogo {
|
||||||
|
imageName = "logoDark"
|
||||||
|
}
|
||||||
|
|
||||||
|
c.getImage(ctx, imageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getBackgroundImageHandler godoc
|
||||||
|
// @Summary Get background image
|
||||||
|
// @Description Get the background image for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/png
|
||||||
|
// @Produce image/jpeg
|
||||||
|
// @Success 200 {file} binary "Background image"
|
||||||
|
// @Router /api/application-images/background [get]
|
||||||
|
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "background")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getFaviconHandler godoc
|
||||||
|
// @Summary Get favicon
|
||||||
|
// @Description Get the favicon for the application
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Produce image/x-icon
|
||||||
|
// @Success 200 {file} binary "Favicon image"
|
||||||
|
// @Router /api/application-images/favicon [get]
|
||||||
|
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
|
||||||
|
c.getImage(ctx, "favicon")
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateLogoHandler godoc
|
||||||
|
// @Summary Update logo
|
||||||
|
// @Description Update the application logo
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
|
// @Param file formData file true "Logo image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/logo [put]
|
||||||
|
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
|
||||||
|
imageName := "logoLight"
|
||||||
|
if !lightLogo {
|
||||||
|
imageName = "logoDark"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, imageName); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateBackgroundImageHandler godoc
|
||||||
|
// @Summary Update background image
|
||||||
|
// @Description Update the application background image
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Background image file"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/background [put]
|
||||||
|
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "background"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateFaviconHandler godoc
|
||||||
|
// @Summary Update favicon
|
||||||
|
// @Description Update the application favicon
|
||||||
|
// @Tags Application Images
|
||||||
|
// @Accept multipart/form-data
|
||||||
|
// @Param file formData file true "Favicon file (.ico)"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/application-images/favicon [put]
|
||||||
|
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||||
|
file, err := ctx.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fileType := utils.GetFileExtension(file.Filename)
|
||||||
|
if fileType != "ico" {
|
||||||
|
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := c.appImagesService.UpdateImage(file, "favicon"); err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
|
||||||
|
imagePath, mimeType, err := c.appImagesService.GetImage(name)
|
||||||
|
if err != nil {
|
||||||
|
_ = ctx.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Header("Content-Type", mimeType)
|
||||||
|
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
|
||||||
|
ctx.File(imagePath)
|
||||||
|
}
|
||||||
@@ -20,7 +20,10 @@ func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.Audi
|
|||||||
auditLogService: auditLogService,
|
auditLogService: auditLogService,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
|
||||||
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
|
||||||
|
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
|
||||||
|
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditLogController struct {
|
type AuditLogController struct {
|
||||||
@@ -31,25 +34,27 @@ type AuditLogController struct {
|
|||||||
// @Summary List audit logs
|
// @Summary List audit logs
|
||||||
// @Description Get a paginated list of audit logs for the current user
|
// @Description Get a paginated list of audit logs for the current user
|
||||||
// @Tags Audit Logs
|
// @Tags Audit Logs
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
// @Router /api/audit-logs [get]
|
// @Router /api/audit-logs [get]
|
||||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
|
||||||
c.Error(err)
|
err := c.ShouldBindQuery(&sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
// Fetch audit logs for the user
|
// Fetch audit logs for the user
|
||||||
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
|
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
var logsDtos []dto.AuditLogDto
|
var logsDtos []dto.AuditLogDto
|
||||||
err = dto.MapStructList(logs, &logsDtos)
|
err = dto.MapStructList(logs, &logsDtos)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,3 +77,87 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listAllAuditLogsHandler godoc
|
||||||
|
// @Summary List all audit logs
|
||||||
|
// @Description Get a paginated list of all audit logs (admin only)
|
||||||
|
// @Tags Audit Logs
|
||||||
|
// @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")
|
||||||
|
// @Param filters[userId] query string false "Filter by user ID"
|
||||||
|
// @Param filters[event] query string false "Filter by event type"
|
||||||
|
// @Param filters[clientName] query string false "Filter by client name"
|
||||||
|
// @Param filters[location] query string false "Filter by location type (external or internal)"
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
|
// @Router /api/audit-logs/all [get]
|
||||||
|
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var filters dto.AuditLogFilterDto
|
||||||
|
if err := c.ShouldBindQuery(&filters); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var logsDtos []dto.AuditLogDto
|
||||||
|
err = dto.MapStructList(logs, &logsDtos)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for i, logsDto := range logsDtos {
|
||||||
|
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||||
|
logsDto.Username = logs[i].User.Username
|
||||||
|
logsDtos[i] = logsDto
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
|
||||||
|
Data: logsDtos,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// listClientNamesHandler godoc
|
||||||
|
// @Summary List client names
|
||||||
|
// @Description Get a list of all client names for audit log filtering
|
||||||
|
// @Tags Audit Logs
|
||||||
|
// @Success 200 {array} string "List of client names"
|
||||||
|
// @Router /api/audit-logs/filters/client-names [get]
|
||||||
|
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
|
||||||
|
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, names)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listUserNamesWithIdsHandler godoc
|
||||||
|
// @Summary List users with IDs
|
||||||
|
// @Description Get a list of all usernames with their IDs for audit log filtering
|
||||||
|
// @Tags Audit Logs
|
||||||
|
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
|
||||||
|
// @Router /api/audit-logs/filters/users [get]
|
||||||
|
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
|
||||||
|
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, users)
|
||||||
|
}
|
||||||
|
|||||||
@@ -35,15 +35,11 @@ type CustomClaimController struct {
|
|||||||
// @Tags Custom Claims
|
// @Tags Custom Claims
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} string "List of suggested custom claim names"
|
// @Success 200 {array} string "List of suggested custom claim names"
|
||||||
// @Failure 401 {object} object "Unauthorized"
|
|
||||||
// @Failure 403 {object} object "Forbidden"
|
|
||||||
// @Failure 500 {object} object "Internal server error"
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/custom-claims/suggestions [get]
|
// @Router /api/custom-claims/suggestions [get]
|
||||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||||
claims, err := ccc.customClaimService.GetSuggestions()
|
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,21 +59,21 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
|||||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||||
var input []dto.CustomClaimCreateDto
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userId := c.Param("userId")
|
userId := c.Param("userId")
|
||||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var customClaimsDto []dto.CustomClaimDto
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,26 +89,25 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
|||||||
// @Param userGroupId path string true "User Group ID"
|
// @Param userGroupId path string true "User Group ID"
|
||||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
||||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||||
var input []dto.CustomClaimCreateDto
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userGroupId := c.Param("userGroupId")
|
userGroupId := c.Param("userGroupId")
|
||||||
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userGroupId, input)
|
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var customClaimsDto []dto.CustomClaimDto
|
var customClaimsDto []dto.CustomClaimDto
|
||||||
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
127
backend/internal/controller/e2etest_controller.go
Normal file
127
backend/internal/controller/e2etest_controller.go
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
//go:build e2etest
|
||||||
|
|
||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
||||||
|
testController := &TestController{TestService: testService}
|
||||||
|
|
||||||
|
group.POST("/test/reset", testController.resetAndSeedHandler)
|
||||||
|
group.POST("/test/refreshtoken", testController.signRefreshToken)
|
||||||
|
|
||||||
|
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
|
||||||
|
group.POST("/externalidp/sign", testController.externalIdPSignToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
type TestController struct {
|
||||||
|
TestService *service.TestService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||||
|
var baseURL string
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
baseURL = "https://" + c.Request.Host
|
||||||
|
} else {
|
||||||
|
baseURL = "http://" + c.Request.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
skipLdap := c.Query("skip-ldap") == "true"
|
||||||
|
skipSeed := c.Query("skip-seed") == "true"
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !skipSeed {
|
||||||
|
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if !skipLdap {
|
||||||
|
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tc.TestService.SetJWTKeys()
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
|
||||||
|
jwks, err := tc.TestService.GetExternalIdPJWKS()
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, jwks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
Iss string `json:"iss"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
}
|
||||||
|
err := c.ShouldBindJSON(&input)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.WriteString(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) signRefreshToken(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
UserID string `json:"user"`
|
||||||
|
ClientID string `json:"client"`
|
||||||
|
RefreshToken string `json:"rt"`
|
||||||
|
}
|
||||||
|
err := c.ShouldBindJSON(&input)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.WriteString(token)
|
||||||
|
}
|
||||||
29
backend/internal/controller/healthz_controller.go
Normal file
29
backend/internal/controller/healthz_controller.go
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewHealthzController creates a new controller for the healthcheck endpoints
|
||||||
|
// @Summary Healthcheck controller
|
||||||
|
// @Description Initializes healthcheck endpoints
|
||||||
|
// @Tags Health
|
||||||
|
func NewHealthzController(r *gin.Engine) {
|
||||||
|
hc := &HealthzController{}
|
||||||
|
|
||||||
|
r.GET("/healthz", hc.healthzHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type HealthzController struct{}
|
||||||
|
|
||||||
|
// healthzHandler godoc
|
||||||
|
// @Summary Responds to healthchecks
|
||||||
|
// @Description Responds with a successful status code to healthcheck requests
|
||||||
|
// @Tags Health
|
||||||
|
// @Success 204 ""
|
||||||
|
// @Router /healthz [get]
|
||||||
|
func (hc *HealthzController) healthzHandler(c *gin.Context) {
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
@@ -1,19 +1,21 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"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/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"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/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewOidcController creates a new controller for OIDC related endpoints
|
// NewOidcController creates a new controller for OIDC related endpoints
|
||||||
@@ -29,8 +31,9 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.POST("/oidc/token", oc.createTokensHandler)
|
group.POST("/oidc/token", oc.createTokensHandler)
|
||||||
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
group.GET("/oidc/userinfo", oc.userInfoHandler)
|
||||||
group.POST("/oidc/userinfo", oc.userInfoHandler)
|
group.POST("/oidc/userinfo", oc.userInfoHandler)
|
||||||
group.POST("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
|
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||||
group.GET("/oidc/end-session", authMiddleware.WithSuccessOptional().Add(), oc.EndSessionHandler)
|
group.GET("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
|
||||||
|
group.POST("/oidc/introspect", oc.introspectTokenHandler)
|
||||||
|
|
||||||
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
|
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
|
||||||
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
|
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
|
||||||
@@ -45,6 +48,20 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||||
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||||
|
|
||||||
|
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
||||||
|
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
||||||
|
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
||||||
|
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
||||||
|
|
||||||
|
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcController struct {
|
type OidcController struct {
|
||||||
@@ -60,24 +77,24 @@ type OidcController struct {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
||||||
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/authorize [post]
|
// @Router /api/oidc/authorize [post]
|
||||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientRequestDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := dto.AuthorizeOidcClientResponseDto{
|
response := dto.AuthorizeOidcClientResponseDto{
|
||||||
Code: code,
|
Code: code,
|
||||||
CallbackURL: callbackURL,
|
CallbackURL: callbackURL,
|
||||||
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
@@ -91,18 +108,17 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
||||||
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/authorization-required [post]
|
// @Router /api/oidc/authorization-required [post]
|
||||||
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizationRequiredDto
|
var input dto.AuthorizationRequiredDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
|
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -115,74 +131,64 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
||||||
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
|
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
|
||||||
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
|
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
|
||||||
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
|
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
|
||||||
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
|
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
|
||||||
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
|
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
|
||||||
|
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
|
||||||
|
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
|
||||||
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
|
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
|
||||||
// @Router /api/oidc/token [post]
|
// @Router /api/oidc/token [post]
|
||||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
// Disable cors for this endpoint
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
|
||||||
|
|
||||||
var input dto.OidcCreateTokensDto
|
var input dto.OidcCreateTokensDto
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that code is provided for authorization_code grant type
|
// Validate that code is provided for authorization_code grant type
|
||||||
if input.GrantType == "authorization_code" && input.Code == "" {
|
if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
|
||||||
c.Error(&common.OidcMissingAuthorizationCodeError{})
|
_ = c.Error(&common.OidcMissingAuthorizationCodeError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate that refresh_token is provided for refresh_token grant type
|
// Validate that refresh_token is provided for refresh_token grant type
|
||||||
if input.GrantType == "refresh_token" && input.RefreshToken == "" {
|
if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
|
||||||
c.Error(&common.OidcMissingRefreshTokenError{})
|
_ = c.Error(&common.OidcMissingRefreshTokenError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientID := input.ClientID
|
|
||||||
clientSecret := input.ClientSecret
|
|
||||||
|
|
||||||
// Client id and secret can also be passed over the Authorization header
|
// Client id and secret can also be passed over the Authorization header
|
||||||
if clientID == "" && clientSecret == "" {
|
if input.ClientID == "" && input.ClientSecret == "" {
|
||||||
clientID, clientSecret, _ = c.Request.BasicAuth()
|
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||||
}
|
}
|
||||||
|
|
||||||
idToken, accessToken, refreshToken, expiresIn, err := oc.oidcService.CreateTokens(
|
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
|
||||||
input.Code,
|
|
||||||
input.GrantType,
|
|
||||||
clientID,
|
|
||||||
clientSecret,
|
|
||||||
input.CodeVerifier,
|
|
||||||
input.RefreshToken,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
switch {
|
||||||
c.Error(err)
|
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "authorization_pending",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case errors.Is(err, &common.OidcSlowDownError{}):
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"error": "slow_down",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
case err != nil:
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
response := dto.OidcTokenResponseDto{
|
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
|
||||||
AccessToken: accessToken,
|
AccessToken: tokens.AccessToken,
|
||||||
TokenType: "Bearer",
|
TokenType: "Bearer",
|
||||||
ExpiresIn: expiresIn,
|
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
|
||||||
}
|
IdToken: tokens.IdToken, // May be empty
|
||||||
|
RefreshToken: tokens.RefreshToken, // May be empty
|
||||||
// Include ID token only for authorization_code grant
|
})
|
||||||
if idToken != "" {
|
|
||||||
response.IdToken = idToken
|
|
||||||
}
|
|
||||||
|
|
||||||
// Include refresh token if generated
|
|
||||||
if refreshToken != "" {
|
|
||||||
response.RefreshToken = refreshToken
|
|
||||||
}
|
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// userInfoHandler godoc
|
// userInfoHandler godoc
|
||||||
@@ -195,49 +201,41 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
|||||||
// @Security OAuth2AccessToken
|
// @Security OAuth2AccessToken
|
||||||
// @Router /api/oidc/userinfo [get]
|
// @Router /api/oidc/userinfo [get]
|
||||||
func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||||
authHeaderSplit := strings.Split(c.GetHeader("Authorization"), " ")
|
_, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
|
||||||
if len(authHeaderSplit) != 2 {
|
if !ok || authToken == "" {
|
||||||
c.Error(&common.MissingAccessToken{})
|
_ = c.Error(&common.MissingAccessToken{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token := authHeaderSplit[1]
|
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
|
||||||
|
|
||||||
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
userID := jwtClaims.Subject
|
userID, ok := token.Subject()
|
||||||
clientId := jwtClaims.Audience[0]
|
if !ok {
|
||||||
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
|
_ = c.Error(&common.TokenInvalidError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientID, ok := token.Audience()
|
||||||
|
if !ok || len(clientID) != 1 {
|
||||||
|
_ = c.Error(&common.TokenInvalidError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, claims)
|
c.JSON(http.StatusOK, claims)
|
||||||
}
|
}
|
||||||
|
|
||||||
// userInfoHandler godoc (POST method)
|
|
||||||
// @Summary Get user information (POST method)
|
|
||||||
// @Description Get user information based on the access token using POST
|
|
||||||
// @Tags OIDC
|
|
||||||
// @Accept json
|
|
||||||
// @Produce json
|
|
||||||
// @Success 200 {object} object "User claims based on requested scopes"
|
|
||||||
// @Security OAuth2AccessToken
|
|
||||||
// @Router /api/oidc/userinfo [post]
|
|
||||||
func (oc *OidcController) userInfoHandlerPost(c *gin.Context) {
|
|
||||||
// Implementation is the same as GET
|
|
||||||
}
|
|
||||||
|
|
||||||
// EndSessionHandler godoc
|
// EndSessionHandler godoc
|
||||||
// @Summary End OIDC session
|
// @Summary End OIDC session
|
||||||
// @Description End user session and handle OIDC logout
|
// @Description End user session and handle OIDC logout
|
||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Accept application/x-www-form-urlencoded
|
// @Accept application/x-www-form-urlencoded
|
||||||
// @Produce html
|
|
||||||
// @Param id_token_hint query string false "ID token"
|
// @Param id_token_hint query string false "ID token"
|
||||||
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
||||||
// @Param state query string false "State parameter to include in the redirect"
|
// @Param state query string false "State parameter to include in the redirect"
|
||||||
@@ -247,23 +245,24 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
|||||||
var input dto.OidcLogoutDto
|
var input dto.OidcLogoutDto
|
||||||
|
|
||||||
// Bind query parameters to the struct
|
// Bind query parameters to the struct
|
||||||
if c.Request.Method == http.MethodGet {
|
switch c.Request.Method {
|
||||||
|
case http.MethodGet:
|
||||||
if err := c.ShouldBindQuery(&input); err != nil {
|
if err := c.ShouldBindQuery(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
} else if c.Request.Method == http.MethodPost {
|
case http.MethodPost:
|
||||||
// Bind form parameters to the struct
|
// Bind form parameters to the struct
|
||||||
if err := c.ShouldBind(&input); err != nil {
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
callbackURL, err := oc.oidcService.ValidateEndSession(input, c.GetString("userID"))
|
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
||||||
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
|
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
|
||||||
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -296,6 +295,49 @@ func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
|
|||||||
// Implementation is the same as GET
|
// Implementation is the same as GET
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// introspectToken godoc
|
||||||
|
// @Summary Introspect OIDC tokens
|
||||||
|
// @Description Pass an access_token to verify if it is considered valid.
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param token formData string true "The token to be introspected."
|
||||||
|
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
|
||||||
|
// @Router /api/oidc/introspect [post]
|
||||||
|
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcIntrospectDto
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client id and secret have to be passed over the Authorization header. This kind of
|
||||||
|
// authentication allows us to keep the endpoint protected (since it could be used to
|
||||||
|
// find valid tokens) while still allowing it to be used by an application that is
|
||||||
|
// supposed to interact with our IdP (since that needs to have a client_id
|
||||||
|
// and client_secret anyway).
|
||||||
|
var (
|
||||||
|
creds service.ClientAuthCredentials
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
// If there's no basic auth, check if we have a bearer token
|
||||||
|
bearer, ok := utils.BearerAuth(c.Request)
|
||||||
|
if ok {
|
||||||
|
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
|
||||||
|
creds.ClientAssertion = bearer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
// getClientMetaDataHandler godoc
|
// getClientMetaDataHandler godoc
|
||||||
// @Summary Get client metadata
|
// @Summary Get client metadata
|
||||||
// @Description Get OIDC client metadata for discovery and configuration
|
// @Description Get OIDC client metadata for discovery and configuration
|
||||||
@@ -306,9 +348,9 @@ func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
|
|||||||
// @Router /api/oidc/clients/{id}/meta [get]
|
// @Router /api/oidc/clients/{id}/meta [get]
|
||||||
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||||
clientId := c.Param("id")
|
clientId := c.Param("id")
|
||||||
client, err := oc.oidcService.GetClient(clientId)
|
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +361,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getClientHandler godoc
|
// getClientHandler godoc
|
||||||
@@ -329,24 +371,23 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id} [get]
|
// @Router /api/oidc/clients/{id} [get]
|
||||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||||
clientId := c.Param("id")
|
clientId := c.Param("id")
|
||||||
client, err := oc.oidcService.GetClient(clientId)
|
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||||
err = dto.MapStruct(client, &clientDto)
|
err = dto.MapStruct(client, &clientDto)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, clientDto)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Error(err)
|
c.JSON(http.StatusOK, clientDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listClientsHandler godoc
|
// listClientsHandler godoc
|
||||||
@@ -354,34 +395,43 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param search query string false "Search term to filter clients by name"
|
// @Param search query string false "Search term to filter clients by name"
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("name")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
|
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients [get]
|
// @Router /api/oidc/clients [get]
|
||||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
|
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientsDto []dto.OidcClientDto
|
// Map the user groups to DTOs
|
||||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
|
||||||
c.Error(err)
|
for i, client := range clients {
|
||||||
return
|
var clientDto dto.OidcClientWithAllowedGroupsCountDto
|
||||||
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
clientsDto[i] = clientDto
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
|
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
|
||||||
Data: clientsDto,
|
Data: clientsDto,
|
||||||
Pagination: pagination,
|
Pagination: pagination,
|
||||||
})
|
})
|
||||||
@@ -395,24 +445,23 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||||
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients [post]
|
// @Router /api/oidc/clients [post]
|
||||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
|
client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,12 +474,11 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id} [delete]
|
// @Router /api/oidc/clients/{id} [delete]
|
||||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClient(c.Param("id"))
|
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -444,26 +492,25 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
// @Param client body dto.OidcClientUpdateDto true "Client information"
|
||||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id} [put]
|
// @Router /api/oidc/clients/{id} [put]
|
||||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
|
client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
var clientDto dto.OidcClientWithAllowedUserGroupsDto
|
||||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -477,12 +524,11 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/secret [post]
|
// @Router /api/oidc/clients/{id}/secret [post]
|
||||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||||
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
|
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -500,12 +546,14 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
|||||||
// @Success 200 {file} binary "Logo image"
|
// @Success 200 {file} binary "Logo image"
|
||||||
// @Router /api/oidc/clients/{id}/logo [get]
|
// @Router /api/oidc/clients/{id}/logo [get]
|
||||||
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||||
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Param("id"))
|
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
c.Header("Content-Type", mimeType)
|
||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
@@ -516,20 +564,19 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
|
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/logo [post]
|
// @Router /api/oidc/clients/{id}/logo [post]
|
||||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
|
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -542,12 +589,11 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/logo [delete]
|
// @Router /api/oidc/clients/{id}/logo [delete]
|
||||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
|
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,26 +609,230 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
|||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||||
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
||||||
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||||
var input dto.OidcUpdateAllowedUserGroupsDto
|
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
|
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var oidcClientDto dto.OidcClientDto
|
var oidcClientDto dto.OidcClientDto
|
||||||
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
|
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, oidcClientDto)
|
c.JSON(http.StatusOK, oidcClientDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||||
|
var input dto.OidcDeviceAuthorizationRequestDto
|
||||||
|
if err := c.ShouldBind(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client id and secret can also be passed over the Authorization header
|
||||||
|
if input.ClientID == "" && input.ClientSecret == "" {
|
||||||
|
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOwnAuthorizedClientsHandler godoc
|
||||||
|
// @Summary List authorized clients for current user
|
||||||
|
// @Description Get a paginated list of OIDC clients that the current user has authorized
|
||||||
|
// @Tags OIDC
|
||||||
|
// @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[dto.AuthorizedOidcClientDto]
|
||||||
|
// @Router /api/oidc/users/me/authorized-clients [get]
|
||||||
|
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
oc.listAuthorizedClients(c, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuthorizedClientsHandler godoc
|
||||||
|
// @Summary List authorized clients for a user
|
||||||
|
// @Description Get a paginated list of OIDC clients that a specific user has authorized
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @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[dto.AuthorizedOidcClientDto]
|
||||||
|
// @Router /api/oidc/users/{id}/authorized-clients [get]
|
||||||
|
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
oc.listAuthorizedClients(c, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the clients to DTOs
|
||||||
|
var authorizedClientsDto []dto.AuthorizedOidcClientDto
|
||||||
|
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
|
||||||
|
Data: authorizedClientsDto,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeOwnClientAuthorizationHandler godoc
|
||||||
|
// @Summary Revoke authorization for an OIDC client
|
||||||
|
// @Description Revoke the authorization for a specific OIDC client for the current user
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param clientId path string true "Client ID to revoke authorization for"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
|
||||||
|
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("clientId")
|
||||||
|
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOwnAccessibleClientsHandler godoc
|
||||||
|
// @Summary List accessible OIDC clients for current user
|
||||||
|
// @Description Get a list of OIDC clients that the current user can access
|
||||||
|
// @Tags OIDC
|
||||||
|
// @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[dto.AccessibleOidcClientDto]
|
||||||
|
// @Router /api/oidc/users/me/clients [get]
|
||||||
|
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
|
||||||
|
Data: clients,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
||||||
|
userCode := c.Query("code")
|
||||||
|
if userCode == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get IP address and user agent from the request context
|
||||||
|
ipAddress := c.ClientIP()
|
||||||
|
userAgent := c.Request.UserAgent()
|
||||||
|
|
||||||
|
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
|
||||||
|
userCode := c.Query("code")
|
||||||
|
if userCode == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "code is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getClientPreviewHandler godoc
|
||||||
|
// @Summary Preview OIDC client data for user
|
||||||
|
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Param userId path string true "User ID to preview data for"
|
||||||
|
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
|
||||||
|
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
|
||||||
|
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("id")
|
||||||
|
userID := c.Param("userId")
|
||||||
|
scopes := c.Query("scopes")
|
||||||
|
|
||||||
|
if clientID == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if scopes == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,44 +0,0 @@
|
|||||||
package controller
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
|
||||||
)
|
|
||||||
|
|
||||||
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
|
|
||||||
testController := &TestController{TestService: testService}
|
|
||||||
|
|
||||||
group.POST("/test/reset", testController.resetAndSeedHandler)
|
|
||||||
}
|
|
||||||
|
|
||||||
type TestController struct {
|
|
||||||
TestService *service.TestService
|
|
||||||
}
|
|
||||||
|
|
||||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
|
||||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tc.TestService.ResetAppConfig(); err != nil {
|
|
||||||
c.Error(err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
tc.TestService.SetJWTKeys()
|
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
|
||||||
}
|
|
||||||
@@ -2,13 +2,11 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"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/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"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/service"
|
||||||
@@ -16,6 +14,11 @@ import (
|
|||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||||
|
defaultSignupTokenDuration = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// NewUserController creates a new controller for user management endpoints
|
// NewUserController creates a new controller for user management endpoints
|
||||||
// @Summary User management controller
|
// @Summary User management controller
|
||||||
// @Description Initializes all user-related API endpoints
|
// @Description Initializes all user-related API endpoints
|
||||||
@@ -44,12 +47,19 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
|
|
||||||
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
|
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
|
||||||
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||||
|
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
||||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||||
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
|
||||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.requestOneTimeAccessEmailHandler)
|
|
||||||
|
|
||||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||||
|
|
||||||
|
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
|
||||||
|
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
||||||
|
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
|
||||||
|
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
|
||||||
|
group.POST("/signup/setup", uc.signUpInitialAdmin)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
@@ -66,15 +76,15 @@ type UserController struct {
|
|||||||
// @Router /api/users/{id}/groups [get]
|
// @Router /api/users/{id}/groups [get]
|
||||||
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
groups, err := uc.userService.GetUserGroups(userID)
|
groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupsDto []dto.UserGroupDtoWithUsers
|
var groupsDto []dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStructList(groups, &groupsDto); err != nil {
|
if err := dto.MapStructList(groups, &groupsDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,29 +96,29 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
|||||||
// @Description Get a paginated list of users with optional search and sorting
|
// @Description Get a paginated list of users with optional search and sorting
|
||||||
// @Tags Users
|
// @Tags Users
|
||||||
// @Param search query string false "Search term to filter users"
|
// @Param search query string false "Search term to filter users"
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
||||||
// @Router /api/users [get]
|
// @Router /api/users [get]
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
|
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var usersDto []dto.UserDto
|
var usersDto []dto.UserDto
|
||||||
if err := dto.MapStructList(users, &usersDto); err != nil {
|
if err := dto.MapStructList(users, &usersDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,15 +136,15 @@ func (uc *UserController) listUsersHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/users/{id} [get]
|
// @Router /api/users/{id} [get]
|
||||||
func (uc *UserController) getUserHandler(c *gin.Context) {
|
func (uc *UserController) getUserHandler(c *gin.Context) {
|
||||||
user, err := uc.userService.GetUser(c.Param("id"))
|
user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,15 +158,15 @@ func (uc *UserController) getUserHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/users/me [get]
|
// @Router /api/users/me [get]
|
||||||
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
||||||
user, err := uc.userService.GetUser(c.GetString("userID"))
|
user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,8 +181,8 @@ func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
|
|||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Router /api/users/{id} [delete]
|
// @Router /api/users/{id} [delete]
|
||||||
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||||
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
|
if err := uc.userService.DeleteUser(c.Request.Context(), c.Param("id"), false); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,20 +198,20 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
|||||||
// @Router /api/users [post]
|
// @Router /api/users [post]
|
||||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := uc.userService.CreateUser(input)
|
user, err := uc.userService.CreateUser(c.Request.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,10 +238,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/users/me [put]
|
// @Router /api/users/me [put]
|
||||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||||
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
|
|
||||||
c.Error(&common.AccountEditNotAllowedError{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uc.updateUser(c, true)
|
uc.updateUser(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,13 +252,16 @@ func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
|
|
||||||
picture, size, err := uc.userService.GetProfilePicture(userID)
|
picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if picture != nil {
|
||||||
|
defer picture.Close()
|
||||||
|
}
|
||||||
|
|
||||||
c.Header("Cache-Control", "public, max-age=300")
|
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
|
||||||
|
|
||||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
}
|
}
|
||||||
@@ -271,18 +280,18 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
|
|||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
fileHeader, err := c.FormFile("file")
|
fileHeader, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := fileHeader.Open()
|
file, err := fileHeader.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -302,18 +311,18 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
fileHeader, err := c.FormFile("file")
|
fileHeader, err := c.FormFile("file")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
file, err := fileHeader.Open()
|
file, err := fileHeader.Open()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer file.Close()
|
defer file.Close()
|
||||||
|
|
||||||
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,16 +332,23 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
|
|||||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
||||||
var input dto.OneTimeAccessTokenCreateDto
|
var input dto.OneTimeAccessTokenCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ttl time.Duration
|
||||||
if own {
|
if own {
|
||||||
input.UserID = c.GetString("userID")
|
input.UserID = c.GetString("userID")
|
||||||
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
|
} else {
|
||||||
|
ttl = input.TTL.Duration
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
|
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,20 +367,69 @@ func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
uc.createOneTimeAccessTokenHandler(c, true)
|
uc.createOneTimeAccessTokenHandler(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createAdminOneTimeAccessTokenHandler godoc
|
||||||
|
// @Summary Create one-time access token for user (admin)
|
||||||
|
// @Description Generate a one-time access token for a specific user (admin only)
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
|
||||||
|
// @Success 201 {object} object "{ \"token\": \"string\" }"
|
||||||
|
// @Router /api/users/{id}/one-time-access-token [post]
|
||||||
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
uc.createOneTimeAccessTokenHandler(c, false)
|
uc.createOneTimeAccessTokenHandler(c, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc
|
||||||
var input dto.OneTimeAccessEmailDto
|
// @Summary Request one-time access email
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
// @Description Request a one-time access email for unauthenticated users
|
||||||
c.Error(err)
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.OneTimeAccessEmailAsUnauthenticatedUserDto true "Email request information"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/one-time-access-email [post]
|
||||||
|
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
||||||
|
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||||
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
|
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RequestOneTimeAccessEmailAsAdminHandler godoc
|
||||||
|
// @Summary Request one-time access email (admin)
|
||||||
|
// @Description Request a one-time access email for a specific user (admin only)
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param body body dto.OneTimeAccessEmailAsAdminDto true "Email request options"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/users/{id}/one-time-access-email [post]
|
||||||
|
func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) {
|
||||||
|
var input dto.OneTimeAccessEmailAsAdminDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := c.Param("id")
|
||||||
|
|
||||||
|
ttl := input.TTL.Duration
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
|
}
|
||||||
|
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,46 +444,53 @@ func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/one-time-access-token/{token} [post]
|
// @Router /api/one-time-access-token/{token} [post]
|
||||||
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||||
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
|
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
|
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||||
maxAge := sessionDurationInMinutesParsed * 60
|
|
||||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSetupAccessTokenHandler godoc
|
// signUpInitialAdmin godoc
|
||||||
// @Summary Setup initial admin
|
// @Summary Sign up initial admin user
|
||||||
// @Description Generate setup access token for initial admin user configuration
|
// @Description Sign up and generate setup access token for initial admin user
|
||||||
// @Tags Users
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.SignUpDto true "User information"
|
||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/one-time-access-token/setup [post]
|
// @Router /api/signup/setup [post]
|
||||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
|
||||||
user, token, err := uc.userService.SetupInitialAdmin()
|
var input dto.SignUpDto
|
||||||
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
|
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||||
maxAge := sessionDurationInMinutesParsed * 60
|
|
||||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
@@ -435,30 +507,158 @@ func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
|||||||
func (uc *UserController) updateUserGroups(c *gin.Context) {
|
func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||||
var input dto.UserUpdateUserGroupDto
|
var input dto.UserUpdateUserGroupDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := uc.userService.UpdateUserGroups(c.Param("id"), input.UserGroupIds)
|
user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createSignupTokenHandler godoc
|
||||||
|
// @Summary Create signup token
|
||||||
|
// @Description Create a new signup token that allows user registration
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
|
||||||
|
// @Success 201 {object} dto.SignupTokenDto
|
||||||
|
// @Router /api/signup-tokens [post]
|
||||||
|
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||||
|
var input dto.SignupTokenCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := input.TTL.Duration
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultSignupTokenDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenDto dto.SignupTokenDto
|
||||||
|
err = dto.MapStruct(signupToken, &tokenDto)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, tokenDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSignupTokensHandler godoc
|
||||||
|
// @Summary List signup tokens
|
||||||
|
// @Description Get a paginated list of signup tokens
|
||||||
|
// @Tags Users
|
||||||
|
// @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[dto.SignupTokenDto]
|
||||||
|
// @Router /api/signup-tokens [get]
|
||||||
|
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokensDto []dto.SignupTokenDto
|
||||||
|
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
|
||||||
|
Data: tokensDto,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSignupTokenHandler godoc
|
||||||
|
// @Summary Delete signup token
|
||||||
|
// @Description Delete a signup token by ID
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "Token ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/signup-tokens/{id} [delete]
|
||||||
|
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
|
||||||
|
tokenID := c.Param("id")
|
||||||
|
|
||||||
|
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signupWithTokenHandler godoc
|
||||||
|
// @Summary Sign up
|
||||||
|
// @Description Create a new user account
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param user body dto.SignUpDto true "User information"
|
||||||
|
// @Success 201 {object} dto.SignUpDto
|
||||||
|
// @Router /api/signup [post]
|
||||||
|
func (uc *UserController) signupHandler(c *gin.Context) {
|
||||||
|
var input dto.SignUpDto
|
||||||
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := c.ClientIP()
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
|
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||||
|
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,15 +669,15 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
|||||||
userID = c.Param("id")
|
userID = c.Param("id")
|
||||||
}
|
}
|
||||||
|
|
||||||
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
|
user, err := uc.userService.UpdateUser(c.Request.Context(), userID, input, updateOwnUser, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -496,7 +696,7 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
|
|||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
|
|
||||||
if err := uc.userService.ResetProfilePicture(userID); err != nil {
|
if err := uc.userService.ResetProfilePicture(userID); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -514,7 +714,7 @@ func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context)
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
if err := uc.userService.ResetProfilePicture(userID); err != nil {
|
if err := uc.userService.ResetProfilePicture(userID); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,23 +40,25 @@ type UserGroupController struct {
|
|||||||
// @Description Get a paginated list of user groups with optional search and sorting
|
// @Description Get a paginated list of user groups with optional search and sorting
|
||||||
// @Tags User Groups
|
// @Tags User Groups
|
||||||
// @Param search query string false "Search term to filter user groups by name"
|
// @Param search query string false "Search term to filter user groups by name"
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("name")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
||||||
// @Router /api/user-groups [get]
|
// @Router /api/user-groups [get]
|
||||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
|
groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,12 +67,12 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
for i, group := range groups {
|
for i, group := range groups {
|
||||||
var groupDto dto.UserGroupDtoWithUserCount
|
var groupDto dto.UserGroupDtoWithUserCount
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
|
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
groupsDto[i] = groupDto
|
groupsDto[i] = groupDto
|
||||||
@@ -90,18 +92,17 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id} [get]
|
// @Router /api/user-groups/{id} [get]
|
||||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||||
group, err := ugc.UserGroupService.Get(c.Param("id"))
|
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -116,24 +117,23 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||||
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups [post]
|
// @Router /api/user-groups [post]
|
||||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.Create(input)
|
group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,24 +149,23 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
|||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||||
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id} [put]
|
// @Router /api/user-groups/{id} [put]
|
||||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
|
group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,11 +180,10 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id} [delete]
|
// @Router /api/user-groups/{id} [delete]
|
||||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||||
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
|
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,24 +199,23 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
|||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id}/users [put]
|
// @Router /api/user-groups/{id}/users [put]
|
||||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||||
var input dto.UserGroupUpdateUsersDto
|
var input dto.UserGroupUpdateUsersDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input.UserIDs)
|
group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var groupDto dto.UserGroupDtoWithUsers
|
var groupDto dto.UserGroupDtoWithUsers
|
||||||
if err := dto.MapStruct(group, &groupDto); err != nil {
|
if err := dto.MapStruct(group, &groupDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
40
backend/internal/controller/version_controller.go
Normal file
40
backend/internal/controller/version_controller.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewVersionController registers version-related routes.
|
||||||
|
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
|
||||||
|
vc := &VersionController{versionService: versionService}
|
||||||
|
group.GET("/version/latest", vc.getLatestVersionHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
type VersionController struct {
|
||||||
|
versionService *service.VersionService
|
||||||
|
}
|
||||||
|
|
||||||
|
// getLatestVersionHandler godoc
|
||||||
|
// @Summary Get latest available version of Pocket ID
|
||||||
|
// @Tags Version
|
||||||
|
// @Produce json
|
||||||
|
// @Success 200 {object} map[string]string "Latest version information"
|
||||||
|
// @Router /api/version/latest [get]
|
||||||
|
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||||
|
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"latestVersion": tag,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
@@ -26,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
|
|||||||
|
|
||||||
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
||||||
|
|
||||||
|
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
|
||||||
|
|
||||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||||
@@ -38,9 +39,9 @@ type WebauthnController struct {
|
|||||||
|
|
||||||
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
options, err := wc.webAuthnService.BeginRegistration(userID)
|
options, err := wc.webAuthnService.BeginRegistration(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,20 +52,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(&common.MissingSessionIdError{})
|
_ = c.Error(&common.MissingSessionIdError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
|
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDto dto.WebauthnCredentialDto
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,9 +73,9 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
||||||
options, err := wc.webAuthnService.BeginLogin()
|
options, err := wc.webAuthnService.BeginLogin(c.Request.Context())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,30 +86,29 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
|
|||||||
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
||||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(&common.MissingSessionIdError{})
|
_ = c.Error(&common.MissingSessionIdError{})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
user, token, err := wc.webAuthnService.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var userDto dto.UserDto
|
var userDto dto.UserDto
|
||||||
if err := dto.MapStruct(user, &userDto); err != nil {
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value)
|
maxAge := int(wc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||||
maxAge := sessionDurationInMinutesParsed * 60
|
|
||||||
cookie.AddAccessTokenCookie(c, maxAge, token)
|
cookie.AddAccessTokenCookie(c, maxAge, token)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
@@ -116,15 +116,15 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
|
|||||||
|
|
||||||
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credentials, err := wc.webAuthnService.ListCredentials(userID)
|
credentials, err := wc.webAuthnService.ListCredentials(c.Request.Context(), userID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDtos []dto.WebauthnCredentialDto
|
var credentialDtos []dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,9 +135,9 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
|||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
credentialID := c.Param("id")
|
credentialID := c.Param("id")
|
||||||
|
|
||||||
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
|
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -150,19 +150,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
|
|||||||
|
|
||||||
var input dto.WebauthnCredentialUpdateDto
|
var input dto.WebauthnCredentialUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
|
credential, err := wc.webAuthnService.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var credentialDto dto.WebauthnCredentialDto
|
var credentialDto dto.WebauthnCredentialDto
|
||||||
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
if err := dto.MapStruct(credential, &credentialDto); err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
|||||||
cookie.AddAccessTokenCookie(c, 0, "")
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(&common.MissingSessionIdError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
|
||||||
|
// Try to create a reauthentication token with WebAuthn
|
||||||
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
|
if err == nil {
|
||||||
|
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If WebAuthn fails, try to create a reauthentication token with the access token
|
||||||
|
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
|
||||||
|
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
package controller
|
package controller
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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/service"
|
||||||
)
|
)
|
||||||
@@ -14,12 +19,23 @@ import (
|
|||||||
// @Tags Well Known
|
// @Tags Well Known
|
||||||
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
|
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
|
||||||
wkc := &WellKnownController{jwtService: jwtService}
|
wkc := &WellKnownController{jwtService: jwtService}
|
||||||
|
|
||||||
|
// Pre-compute the OIDC configuration document, which is static
|
||||||
|
var err error
|
||||||
|
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
|
||||||
|
os.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||||
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
|
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
|
||||||
}
|
}
|
||||||
|
|
||||||
type WellKnownController struct {
|
type WellKnownController struct {
|
||||||
jwtService *service.JwtService
|
jwtService *service.JwtService
|
||||||
|
oidcConfig []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
// jwksHandler godoc
|
// jwksHandler godoc
|
||||||
@@ -32,7 +48,7 @@ type WellKnownController struct {
|
|||||||
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
||||||
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
|
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,20 +62,35 @@ func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} object "OpenID Connect configuration"
|
// @Success 200 {object} object "OpenID Connect configuration"
|
||||||
// @Router /.well-known/openid-configuration [get]
|
// @Router /.well-known/openid-configuration [get]
|
||||||
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||||
appUrl := common.EnvConfig.AppURL
|
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
|
||||||
config := map[string]interface{}{
|
}
|
||||||
"issuer": appUrl,
|
|
||||||
"authorization_endpoint": appUrl + "/authorize",
|
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||||
"token_endpoint": appUrl + "/api/oidc/token",
|
appUrl := common.EnvConfig.AppURL
|
||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
|
||||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
internalAppUrl := common.EnvConfig.InternalAppURL
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
|
||||||
"grant_types_supported": []string{"authorization_code", "refresh_token"},
|
alg, err := wkc.jwtService.GetKeyAlg()
|
||||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
if err != nil {
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
}
|
||||||
"subject_types_supported": []string{"public"},
|
config := map[string]any{
|
||||||
"id_token_signing_alg_values_supported": []string{"RS256"},
|
"issuer": appUrl,
|
||||||
}
|
"authorization_endpoint": appUrl + "/authorize",
|
||||||
c.JSON(http.StatusOK, config)
|
"token_endpoint": internalAppUrl + "/api/oidc/token",
|
||||||
|
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
|
||||||
|
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||||
|
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
|
||||||
|
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||||
|
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
|
||||||
|
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
|
||||||
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||||
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
|
"subject_types_supported": []string{"public"},
|
||||||
|
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||||
|
"authorization_response_iss_parameter_supported": true,
|
||||||
|
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||||
|
}
|
||||||
|
return json.Marshal(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,18 +5,19 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ApiKeyCreateDto struct {
|
type ApiKeyCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,min=3,max=50"`
|
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description" unorm:"nfc"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyDto struct {
|
type ApiKeyDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
ExpirationEmailSent bool `json:"expirationEmailSent"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyResponseDto struct {
|
type ApiKeyResponseDto struct {
|
||||||
|
|||||||
@@ -12,35 +12,45 @@ type AppConfigVariableDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfigUpdateDto struct {
|
type AppConfigUpdateDto struct {
|
||||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||||
SmtHost string `json:"smtpHost"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
SmtpPort string `json:"smtpPort"`
|
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||||
SmtpUser string `json:"smtpUser"`
|
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||||
SmtpPassword string `json:"smtpPassword"`
|
AccentColor string `json:"accentColor"`
|
||||||
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
|
||||||
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
SmtpHost string `json:"smtpHost"`
|
||||||
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
SmtpPort string `json:"smtpPort"`
|
||||||
LdapUrl string `json:"ldapUrl"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
LdapBindDn string `json:"ldapBindDn"`
|
SmtpUser string `json:"smtpUser"`
|
||||||
LdapBindPassword string `json:"ldapBindPassword"`
|
SmtpPassword string `json:"smtpPassword"`
|
||||||
LdapBase string `json:"ldapBase"`
|
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
|
||||||
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
|
||||||
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
LdapEnabled string `json:"ldapEnabled" binding:"required"`
|
||||||
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
LdapUrl string `json:"ldapUrl"`
|
||||||
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
LdapBindDn string `json:"ldapBindDn"`
|
||||||
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
LdapBindPassword string `json:"ldapBindPassword"`
|
||||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
LdapBase string `json:"ldapBase"`
|
||||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
|
||||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
|
||||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
|
||||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
|
||||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
|
||||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||||
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
|
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||||
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
|
||||||
|
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||||
|
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||||
|
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||||
|
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||||
|
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||||
|
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
||||||
|
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
|
||||||
|
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||||
|
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
|
||||||
|
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,11 +8,19 @@ type AuditLogDto struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event string `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserID string `json:"userID"`
|
UserID string `json:"userID"`
|
||||||
Data model.AuditLogData `json:"data"`
|
Username string `json:"username"`
|
||||||
|
Data map[string]string `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuditLogFilterDto struct {
|
||||||
|
UserID string `form:"filters[userId]"`
|
||||||
|
Event string `form:"filters[event]"`
|
||||||
|
ClientName string `form:"filters[clientName]"`
|
||||||
|
Location string `form:"filters[location]"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CustomClaimCreateDto struct {
|
type CustomClaimCreateDto struct {
|
||||||
Key string `json:"key" binding:"required"`
|
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||||
Value string `json:"value" binding:"required"`
|
Value string `json:"value" binding:"required" unorm:"nfc"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,117 +1,27 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"reflect"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
"github.com/jinzhu/copier"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MapStructList maps a list of source structs to a list of destination structs
|
// MapStructList maps a list of source structs to a list of destination structs
|
||||||
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
|
||||||
*destination = make([]D, 0, len(source))
|
*destination = make([]D, len(source))
|
||||||
|
|
||||||
for _, item := range source {
|
for i, item := range source {
|
||||||
var destItem D
|
err = MapStruct(item, &((*destination)[i]))
|
||||||
if err := MapStruct(item, &destItem); err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to map field %d: %w", i, err)
|
||||||
}
|
}
|
||||||
*destination = append(*destination, destItem)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapStruct maps a source struct to a destination struct
|
// MapStruct maps a source struct to a destination struct
|
||||||
func MapStruct[S any, D any](source S, destination *D) error {
|
func MapStruct(source any, destination any) error {
|
||||||
// Ensure destination is a non-nil pointer
|
return copier.CopyWithOption(destination, source, copier.Option{
|
||||||
destValue := reflect.ValueOf(destination)
|
DeepCopy: true,
|
||||||
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
|
})
|
||||||
return errors.New("destination must be a non-nil pointer to a struct")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure source is a struct
|
|
||||||
sourceValue := reflect.ValueOf(source)
|
|
||||||
if sourceValue.Kind() != reflect.Struct {
|
|
||||||
return errors.New("source must be a struct")
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapStructInternal(sourceValue, destValue.Elem())
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
|
||||||
// Loop through the fields of the destination struct
|
|
||||||
for i := 0; i < destVal.NumField(); i++ {
|
|
||||||
destField := destVal.Field(i)
|
|
||||||
destFieldType := destVal.Type().Field(i)
|
|
||||||
|
|
||||||
if destFieldType.Anonymous {
|
|
||||||
// Recursively handle embedded structs
|
|
||||||
if err := mapStructInternal(sourceVal, destField); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceField := sourceVal.FieldByName(destFieldType.Name)
|
|
||||||
|
|
||||||
// If the source field is valid and can be assigned to the destination field
|
|
||||||
if sourceField.IsValid() && destField.CanSet() {
|
|
||||||
// Handle direct assignment for simple types
|
|
||||||
if sourceField.Type() == destField.Type() {
|
|
||||||
destField.Set(sourceField)
|
|
||||||
|
|
||||||
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
|
|
||||||
// Handle slices
|
|
||||||
if sourceField.Type().Elem() == destField.Type().Elem() {
|
|
||||||
// Direct assignment for slices of primitive types or non-struct elements
|
|
||||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
|
||||||
|
|
||||||
for j := 0; j < sourceField.Len(); j++ {
|
|
||||||
newSlice.Index(j).Set(sourceField.Index(j))
|
|
||||||
}
|
|
||||||
|
|
||||||
destField.Set(newSlice)
|
|
||||||
|
|
||||||
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
|
|
||||||
// Recursively map slices of structs
|
|
||||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
|
||||||
|
|
||||||
for j := 0; j < sourceField.Len(); j++ {
|
|
||||||
// Get the element from both source and destination slice
|
|
||||||
sourceElem := sourceField.Index(j)
|
|
||||||
destElem := reflect.New(destField.Type().Elem()).Elem()
|
|
||||||
|
|
||||||
// Recursively map the struct elements
|
|
||||||
if err := mapStructInternal(sourceElem, destElem); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the mapped element in the new slice
|
|
||||||
newSlice.Index(j).Set(destElem)
|
|
||||||
}
|
|
||||||
|
|
||||||
destField.Set(newSlice)
|
|
||||||
}
|
|
||||||
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
|
|
||||||
// Recursively map nested structs
|
|
||||||
if err := mapStructInternal(sourceField, destField); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Type switch for specific type conversions
|
|
||||||
switch sourceField.Interface().(type) {
|
|
||||||
case datatype.DateTime:
|
|
||||||
// Convert datatype.DateTime to time.Time
|
|
||||||
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
|
|
||||||
dateValue := sourceField.Interface().(datatype.DateTime)
|
|
||||||
destField.Set(reflect.ValueOf(dateValue.ToTime()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
197
backend/internal/dto/dto_mapper_test.go
Normal file
197
backend/internal/dto/dto_mapper_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceStruct struct {
|
||||||
|
AString string
|
||||||
|
AStringPtr *string
|
||||||
|
ABool bool
|
||||||
|
ABoolPtr *bool
|
||||||
|
ACustomDateTime datatype.DateTime
|
||||||
|
ACustomDateTimePtr *datatype.DateTime
|
||||||
|
ANilStringPtr *string
|
||||||
|
ASlice []string
|
||||||
|
AMap map[string]int
|
||||||
|
AStruct embeddedStruct
|
||||||
|
AStructPtr *embeddedStruct
|
||||||
|
|
||||||
|
StringPtrToString *string
|
||||||
|
EmptyStringPtrToString *string
|
||||||
|
NilStringPtrToString *string
|
||||||
|
IntToInt64 int
|
||||||
|
AuditLogEventToString model.AuditLogEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type destStruct struct {
|
||||||
|
AString string
|
||||||
|
AStringPtr *string
|
||||||
|
ABool bool
|
||||||
|
ABoolPtr *bool
|
||||||
|
ACustomDateTime datatype.DateTime
|
||||||
|
ACustomDateTimePtr *datatype.DateTime
|
||||||
|
ANilStringPtr *string
|
||||||
|
ASlice []string
|
||||||
|
AMap map[string]int
|
||||||
|
AStruct embeddedStruct
|
||||||
|
AStructPtr *embeddedStruct
|
||||||
|
|
||||||
|
StringPtrToString string
|
||||||
|
EmptyStringPtrToString string
|
||||||
|
NilStringPtrToString string
|
||||||
|
IntToInt64 int64
|
||||||
|
AuditLogEventToString string
|
||||||
|
}
|
||||||
|
|
||||||
|
type embeddedStruct struct {
|
||||||
|
Foo string
|
||||||
|
Bar int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStruct(t *testing.T) {
|
||||||
|
src := sourceStruct{
|
||||||
|
AString: "abcd",
|
||||||
|
AStringPtr: utils.Ptr("xyz"),
|
||||||
|
ABool: true,
|
||||||
|
ABoolPtr: utils.Ptr(false),
|
||||||
|
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
|
||||||
|
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||||
|
ANilStringPtr: nil,
|
||||||
|
ASlice: []string{"a", "b", "c"},
|
||||||
|
AMap: map[string]int{
|
||||||
|
"a": 1,
|
||||||
|
"b": 2,
|
||||||
|
},
|
||||||
|
AStruct: embeddedStruct{
|
||||||
|
Foo: "bar",
|
||||||
|
Bar: 42,
|
||||||
|
},
|
||||||
|
AStructPtr: &embeddedStruct{
|
||||||
|
Foo: "quo",
|
||||||
|
Bar: 111,
|
||||||
|
},
|
||||||
|
|
||||||
|
StringPtrToString: utils.Ptr("foobar"),
|
||||||
|
EmptyStringPtrToString: utils.Ptr(""),
|
||||||
|
NilStringPtrToString: nil,
|
||||||
|
IntToInt64: 99,
|
||||||
|
AuditLogEventToString: model.AuditLogEventAccountCreated,
|
||||||
|
}
|
||||||
|
var dst destStruct
|
||||||
|
err := MapStruct(src, &dst)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, src.AString, dst.AString)
|
||||||
|
_ = assert.NotNil(t, src.AStringPtr) &&
|
||||||
|
assert.Equal(t, *src.AStringPtr, *dst.AStringPtr)
|
||||||
|
assert.Equal(t, src.ABool, dst.ABool)
|
||||||
|
_ = assert.NotNil(t, src.ABoolPtr) &&
|
||||||
|
assert.Equal(t, *src.ABoolPtr, *dst.ABoolPtr)
|
||||||
|
assert.Equal(t, src.ACustomDateTime, dst.ACustomDateTime)
|
||||||
|
_ = assert.NotNil(t, src.ACustomDateTimePtr) &&
|
||||||
|
assert.Equal(t, *src.ACustomDateTimePtr, *dst.ACustomDateTimePtr)
|
||||||
|
assert.Nil(t, dst.ANilStringPtr)
|
||||||
|
assert.Equal(t, src.ASlice, dst.ASlice)
|
||||||
|
assert.Equal(t, src.AMap, dst.AMap)
|
||||||
|
assert.Equal(t, "bar", dst.AStruct.Foo)
|
||||||
|
assert.Equal(t, int64(42), dst.AStruct.Bar)
|
||||||
|
_ = assert.NotNil(t, src.AStructPtr) &&
|
||||||
|
assert.Equal(t, "quo", dst.AStructPtr.Foo) &&
|
||||||
|
assert.Equal(t, int64(111), dst.AStructPtr.Bar)
|
||||||
|
assert.Equal(t, "foobar", dst.StringPtrToString)
|
||||||
|
assert.Empty(t, dst.EmptyStringPtrToString)
|
||||||
|
assert.Empty(t, dst.NilStringPtrToString)
|
||||||
|
assert.Equal(t, int64(99), dst.IntToInt64)
|
||||||
|
assert.Equal(t, "ACCOUNT_CREATED", dst.AuditLogEventToString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStructList(t *testing.T) {
|
||||||
|
sources := []sourceStruct{
|
||||||
|
{
|
||||||
|
AString: "first",
|
||||||
|
AStringPtr: utils.Ptr("one"),
|
||||||
|
ABool: true,
|
||||||
|
ABoolPtr: utils.Ptr(false),
|
||||||
|
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
|
||||||
|
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||||
|
ASlice: []string{"a", "b"},
|
||||||
|
AMap: map[string]int{
|
||||||
|
"a": 1,
|
||||||
|
"b": 2,
|
||||||
|
},
|
||||||
|
AStruct: embeddedStruct{
|
||||||
|
Foo: "first_struct",
|
||||||
|
Bar: 10,
|
||||||
|
},
|
||||||
|
IntToInt64: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AString: "second",
|
||||||
|
AStringPtr: utils.Ptr("two"),
|
||||||
|
ABool: false,
|
||||||
|
ABoolPtr: utils.Ptr(true),
|
||||||
|
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
|
||||||
|
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
|
||||||
|
ASlice: []string{"c", "d", "e"},
|
||||||
|
AMap: map[string]int{
|
||||||
|
"c": 3,
|
||||||
|
"d": 4,
|
||||||
|
},
|
||||||
|
AStruct: embeddedStruct{
|
||||||
|
Foo: "second_struct",
|
||||||
|
Bar: 20,
|
||||||
|
},
|
||||||
|
IntToInt64: 20,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinations []destStruct
|
||||||
|
err := MapStructList(sources, &destinations)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, destinations, 2)
|
||||||
|
|
||||||
|
// Verify first element
|
||||||
|
assert.Equal(t, "first", destinations[0].AString)
|
||||||
|
assert.Equal(t, "one", *destinations[0].AStringPtr)
|
||||||
|
assert.True(t, destinations[0].ABool)
|
||||||
|
assert.False(t, *destinations[0].ABoolPtr)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)), destinations[0].ACustomDateTime)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)), *destinations[0].ACustomDateTimePtr)
|
||||||
|
assert.Equal(t, []string{"a", "b"}, destinations[0].ASlice)
|
||||||
|
assert.Equal(t, map[string]int{"a": 1, "b": 2}, destinations[0].AMap)
|
||||||
|
assert.Equal(t, "first_struct", destinations[0].AStruct.Foo)
|
||||||
|
assert.Equal(t, int64(10), destinations[0].AStruct.Bar)
|
||||||
|
assert.Equal(t, int64(10), destinations[0].IntToInt64)
|
||||||
|
|
||||||
|
// Verify second element
|
||||||
|
assert.Equal(t, "second", destinations[1].AString)
|
||||||
|
assert.Equal(t, "two", *destinations[1].AStringPtr)
|
||||||
|
assert.False(t, destinations[1].ABool)
|
||||||
|
assert.True(t, *destinations[1].ABoolPtr)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)), destinations[1].ACustomDateTime)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC)), *destinations[1].ACustomDateTimePtr)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, destinations[1].ASlice)
|
||||||
|
assert.Equal(t, map[string]int{"c": 3, "d": 4}, destinations[1].AMap)
|
||||||
|
assert.Equal(t, "second_struct", destinations[1].AStruct.Foo)
|
||||||
|
assert.Equal(t, int64(20), destinations[1].AStruct.Bar)
|
||||||
|
assert.Equal(t, int64(20), destinations[1].IntToInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStructList_EmptySource(t *testing.T) {
|
||||||
|
var sources []sourceStruct
|
||||||
|
var destinations []destStruct
|
||||||
|
|
||||||
|
err := MapStructList(sources, &destinations)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, destinations)
|
||||||
|
}
|
||||||
94
backend/internal/dto/dto_normalize.go
Normal file
94
backend/internal/dto/dto_normalize.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"reflect"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
|
||||||
|
func Normalize(obj any) {
|
||||||
|
v := reflect.ValueOf(obj)
|
||||||
|
if v.Kind() != reflect.Ptr || v.IsNil() {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
v = v.Elem()
|
||||||
|
|
||||||
|
// Handle case where obj is a slice of models
|
||||||
|
if v.Kind() == reflect.Slice {
|
||||||
|
for i := 0; i < v.Len(); i++ {
|
||||||
|
elem := v.Index(i)
|
||||||
|
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
|
||||||
|
Normalize(elem.Interface())
|
||||||
|
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
|
||||||
|
Normalize(elem.Addr().Interface())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if v.Kind() != reflect.Struct {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through all fields looking for those with the "unorm" tag
|
||||||
|
t := v.Type()
|
||||||
|
loop:
|
||||||
|
for i := range t.NumField() {
|
||||||
|
field := t.Field(i)
|
||||||
|
|
||||||
|
unormTag := field.Tag.Get("unorm")
|
||||||
|
if unormTag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fv := v.Field(i)
|
||||||
|
if !fv.CanSet() || fv.Kind() != reflect.String {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var form norm.Form
|
||||||
|
switch unormTag {
|
||||||
|
case "nfc":
|
||||||
|
form = norm.NFC
|
||||||
|
case "nfkc":
|
||||||
|
form = norm.NFKC
|
||||||
|
case "nfd":
|
||||||
|
form = norm.NFD
|
||||||
|
case "nfkd":
|
||||||
|
form = norm.NFKD
|
||||||
|
default:
|
||||||
|
continue loop
|
||||||
|
}
|
||||||
|
|
||||||
|
val := fv.String()
|
||||||
|
val = form.String(val)
|
||||||
|
fv.SetString(val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
|
||||||
|
return ctx.ShouldBindWith(obj, binding.JSON)
|
||||||
|
}
|
||||||
|
|
||||||
|
type NormalizerJSONBinding struct{}
|
||||||
|
|
||||||
|
func (NormalizerJSONBinding) Name() string {
|
||||||
|
return "json"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error {
|
||||||
|
// Use the default JSON binder
|
||||||
|
err := binding.JSON.Bind(req, obj)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform normalization
|
||||||
|
Normalize(obj)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
84
backend/internal/dto/dto_normalize_test.go
Normal file
84
backend/internal/dto/dto_normalize_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type testDto struct {
|
||||||
|
Name string `unorm:"nfc"`
|
||||||
|
Description string `unorm:"nfd"`
|
||||||
|
Other string
|
||||||
|
BadForm string `unorm:"bad"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalize(t *testing.T) {
|
||||||
|
input := testDto{
|
||||||
|
// Is in NFC form already
|
||||||
|
Name: norm.NFC.String("Café"),
|
||||||
|
// NFC form will be normalized to NFD
|
||||||
|
Description: norm.NFC.String("vërø"),
|
||||||
|
// Should be unchanged
|
||||||
|
Other: "NöTag",
|
||||||
|
// Should be unchanged
|
||||||
|
BadForm: "BåD",
|
||||||
|
}
|
||||||
|
|
||||||
|
Normalize(&input)
|
||||||
|
|
||||||
|
assert.Equal(t, norm.NFC.String("Café"), input.Name)
|
||||||
|
assert.Equal(t, norm.NFD.String("vërø"), input.Description)
|
||||||
|
assert.Equal(t, "NöTag", input.Other)
|
||||||
|
assert.Equal(t, "BåD", input.BadForm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeSlice(t *testing.T) {
|
||||||
|
obj1 := testDto{
|
||||||
|
Name: norm.NFC.String("Café1"),
|
||||||
|
Description: norm.NFC.String("vërø1"),
|
||||||
|
Other: "NöTag1",
|
||||||
|
BadForm: "BåD1",
|
||||||
|
}
|
||||||
|
obj2 := testDto{
|
||||||
|
Name: norm.NFD.String("Résumé2"),
|
||||||
|
Description: norm.NFD.String("accéléré2"),
|
||||||
|
Other: "NöTag2",
|
||||||
|
BadForm: "BåD2",
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("slice of structs", func(t *testing.T) {
|
||||||
|
slice := []testDto{obj1, obj2}
|
||||||
|
Normalize(&slice)
|
||||||
|
|
||||||
|
// Verify first element
|
||||||
|
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
|
||||||
|
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
|
||||||
|
assert.Equal(t, "NöTag1", slice[0].Other)
|
||||||
|
assert.Equal(t, "BåD1", slice[0].BadForm)
|
||||||
|
|
||||||
|
// Verify second element
|
||||||
|
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
|
||||||
|
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
|
||||||
|
assert.Equal(t, "NöTag2", slice[1].Other)
|
||||||
|
assert.Equal(t, "BåD2", slice[1].BadForm)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("slice of pointers to structs", func(t *testing.T) {
|
||||||
|
slice := []*testDto{&obj1, &obj2}
|
||||||
|
Normalize(&slice)
|
||||||
|
|
||||||
|
// Verify first element
|
||||||
|
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
|
||||||
|
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
|
||||||
|
assert.Equal(t, "NöTag1", slice[0].Other)
|
||||||
|
assert.Equal(t, "BåD1", slice[0].BadForm)
|
||||||
|
|
||||||
|
// Verify second element
|
||||||
|
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
|
||||||
|
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
|
||||||
|
assert.Equal(t, "NöTag2", slice[1].Other)
|
||||||
|
assert.Equal(t, "BåD2", slice[1].BadForm)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,17 +1,22 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
|
||||||
type OidcClientMetaDataDto struct {
|
type OidcClientMetaDataDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
LaunchURL *string `json:"launchURL"`
|
||||||
|
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
OidcClientMetaDataDto
|
OidcClientMetaDataDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
IsPublic bool `json:"isPublic"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientWithAllowedUserGroupsDto struct {
|
type OidcClientWithAllowedUserGroupsDto struct {
|
||||||
@@ -19,26 +24,54 @@ type OidcClientWithAllowedUserGroupsDto struct {
|
|||||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcClientWithAllowedGroupsCountDto struct {
|
||||||
|
OidcClientDto
|
||||||
|
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientUpdateDto struct {
|
||||||
|
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||||
|
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
|
||||||
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
|
||||||
|
IsPublic bool `json:"isPublic"`
|
||||||
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||||
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
|
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||||
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
LogoURL *string `json:"logoUrl"`
|
||||||
|
}
|
||||||
|
|
||||||
type OidcClientCreateDto struct {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
OidcClientUpdateDto
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
|
||||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
}
|
||||||
IsPublic bool `json:"isPublic"`
|
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
type OidcClientCredentialsDto struct {
|
||||||
|
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientFederatedIdentityDto struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Audience string `json:"audience,omitempty"`
|
||||||
|
JWKS string `json:"jwks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
CodeChallenge string `json:"codeChallenge"`
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
|
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizationRequiredDto struct {
|
type AuthorizationRequiredDto struct {
|
||||||
@@ -47,12 +80,20 @@ type AuthorizationRequiredDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcCreateTokensDto struct {
|
type OidcCreateTokensDto struct {
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
Code string `form:"code"`
|
Code string `form:"code"`
|
||||||
ClientID string `form:"client_id"`
|
DeviceCode string `form:"device_code"`
|
||||||
ClientSecret string `form:"client_secret"`
|
ClientID string `form:"client_id"`
|
||||||
CodeVerifier string `form:"code_verifier"`
|
ClientSecret string `form:"client_secret"`
|
||||||
RefreshToken string `form:"refresh_token"`
|
CodeVerifier string `form:"code_verifier"`
|
||||||
|
RefreshToken string `form:"refresh_token"`
|
||||||
|
ClientAssertion string `form:"client_assertion"`
|
||||||
|
ClientAssertionType string `form:"client_assertion_type"`
|
||||||
|
Resource string `form:"resource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcIntrospectDto struct {
|
||||||
|
Token string `form:"token" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcUpdateAllowedUserGroupsDto struct {
|
type OidcUpdateAllowedUserGroupsDto struct {
|
||||||
@@ -73,3 +114,64 @@ type OidcTokenResponseDto struct {
|
|||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
ExpiresIn int `json:"expires_in"`
|
ExpiresIn int `json:"expires_in"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcIntrospectionResponseDto struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
TokenType string `json:"token_type,omitempty"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
Expiration int64 `json:"exp,omitempty"`
|
||||||
|
IssuedAt int64 `json:"iat,omitempty"`
|
||||||
|
NotBefore int64 `json:"nbf,omitempty"`
|
||||||
|
Subject string `json:"sub,omitempty"`
|
||||||
|
Audience []string `json:"aud,omitempty"`
|
||||||
|
Issuer string `json:"iss,omitempty"`
|
||||||
|
Identifier string `json:"jti,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcDeviceAuthorizationRequestDto struct {
|
||||||
|
ClientID string `form:"client_id" binding:"required"`
|
||||||
|
Scope string `form:"scope" binding:"required"`
|
||||||
|
ClientSecret string `form:"client_secret"`
|
||||||
|
ClientAssertion string `form:"client_assertion"`
|
||||||
|
ClientAssertionType string `form:"client_assertion_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcDeviceAuthorizationResponseDto struct {
|
||||||
|
DeviceCode string `json:"device_code"`
|
||||||
|
UserCode string `json:"user_code"`
|
||||||
|
VerificationURI string `json:"verification_uri"`
|
||||||
|
VerificationURIComplete string `json:"verification_uri_complete"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Interval int `json:"interval"`
|
||||||
|
RequiresAuthorization bool `json:"requires_authorization"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcDeviceTokenRequestDto struct {
|
||||||
|
GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
|
||||||
|
DeviceCode string `form:"device_code" binding:"required"`
|
||||||
|
ClientID string `form:"client_id"`
|
||||||
|
ClientSecret string `form:"client_secret"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DeviceCodeInfoDto struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
AuthorizationRequired bool `json:"authorizationRequired"`
|
||||||
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AuthorizedOidcClientDto struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
|
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientPreviewDto struct {
|
||||||
|
IdToken map[string]any `json:"idToken"`
|
||||||
|
AccessToken map[string]any `json:"accessToken"`
|
||||||
|
UserInfo map[string]any `json:"userInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessibleOidcClientDto struct {
|
||||||
|
OidcClientMetaDataDto
|
||||||
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|||||||
20
backend/internal/dto/signup_token_dto.go
Normal file
20
backend/internal/dto/signup_token_dto.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTokenCreateDto struct {
|
||||||
|
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||||
|
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignupTokenDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
|
UsageLimit int `json:"usageLimit"`
|
||||||
|
UsageCount int `json:"usageCount"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -1,40 +1,72 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email" `
|
Email *string `json:"email" `
|
||||||
FirstName string `json:"firstName"`
|
FirstName string `json:"firstName"`
|
||||||
LastName string `json:"lastName"`
|
LastName *string `json:"lastName"`
|
||||||
|
DisplayName string `json:"displayName"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||||
UserGroups []UserGroupDto `json:"userGroups"`
|
UserGroups []UserGroupDto `json:"userGroups"`
|
||||||
LdapID *string `json:"ldapId"`
|
LdapID *string `json:"ldapId"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"required,min=1,max=50"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||||
Locale *string `json:"locale"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
LdapID string `json:"-"`
|
Locale *string `json:"locale"`
|
||||||
|
Disabled bool `json:"disabled"`
|
||||||
|
LdapID string `json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u UserCreateDto) Validate() error {
|
||||||
|
e, ok := binding.Validator.Engine().(interface {
|
||||||
|
Struct(s any) error
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return errors.New("validator does not implement the expected interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Struct(u)
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessTokenCreateDto struct {
|
type OneTimeAccessTokenCreateDto struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessEmailDto struct {
|
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||||
RedirectPath string `json:"redirectPath"`
|
RedirectPath string `json:"redirectPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OneTimeAccessEmailAsAdminDto struct {
|
||||||
|
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||||
|
}
|
||||||
|
|
||||||
type UserUpdateUserGroupDto struct {
|
type UserUpdateUserGroupDto struct {
|
||||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignUpDto struct {
|
||||||
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
|
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||||
|
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||||
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
}
|
||||||
|
|||||||
105
backend/internal/dto/user_dto_test.go
Normal file
105
backend/internal/dto/user_dto_test.go
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestUserCreateDto_Validate(t *testing.T) {
|
||||||
|
testCases := []struct {
|
||||||
|
name string
|
||||||
|
input UserCreateDto
|
||||||
|
wantErr string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid input",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: utils.Ptr("test@example.com"),
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
|
wantErr: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing username",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Email: utils.Ptr("test@example.com"),
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing display name",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Email: utils.Ptr("test@example.com"),
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username contains invalid characters",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "test/ser",
|
||||||
|
Email: utils.Ptr("test@example.com"),
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'Username' failed on the 'username' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid email",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: utils.Ptr("not-an-email"),
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'Email' failed on the 'email' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first name too short",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: utils.Ptr("test@example.com"),
|
||||||
|
FirstName: "",
|
||||||
|
LastName: "Doe",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last name too long",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "testuser",
|
||||||
|
Email: utils.Ptr("test@example.com"),
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||||
|
DisplayName: "John Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := tc.input.Validate()
|
||||||
|
|
||||||
|
if tc.wantErr == "" {
|
||||||
|
require.NoError(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.ErrorContains(t, err, tc.wantErr)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,11 +37,22 @@ type UserGroupDtoWithUserCount struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupCreateDto struct {
|
type UserGroupCreateDto struct {
|
||||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
|
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||||
LdapID string `json:"-"`
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (g UserGroupCreateDto) Validate() error {
|
||||||
|
e, ok := binding.Validator.Engine().(interface {
|
||||||
|
Struct(s any) error
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
return errors.New("validator does not implement the expected interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
return e.Struct(g)
|
||||||
|
}
|
||||||
|
|
||||||
type UserGroupUpdateUsersDto struct {
|
type UserGroupUpdateUsersDto struct {
|
||||||
UserIDs []string `json:"userIds" binding:"required"`
|
UserIDs []string `json:"userIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,25 +1,85 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/url"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
"log"
|
|
||||||
"regexp"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||||
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||||
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
|
||||||
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
|
|
||||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||||
return matched
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
v := binding.Validator.Engine().(*validator.Validate)
|
||||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
|
||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
// Maximum allowed value for TTLs
|
||||||
|
const maxTTL = 31 * 24 * time.Hour
|
||||||
|
|
||||||
|
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||||
|
return ValidateUsername(fl.Field().String())
|
||||||
|
}); err != nil {
|
||||||
|
panic("Failed to register custom validation for username: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
|
||||||
|
return ValidateClientID(fl.Field().String())
|
||||||
|
}); err != nil {
|
||||||
|
panic("Failed to register custom validation for client_id: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
|
||||||
|
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
// Allow zero, which means the field wasn't set
|
||||||
|
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
|
||||||
|
}); err != nil {
|
||||||
|
panic("Failed to register custom validation for ttl: " + err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
|
||||||
|
return ValidateCallbackURL(fl.Field().String())
|
||||||
|
}); err != nil {
|
||||||
|
panic("Failed to register custom validation for callback_url: " + err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateUsername validates username inputs
|
||||||
|
func ValidateUsername(username string) bool {
|
||||||
|
return validateUsernameRegex.MatchString(username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateClientID validates client ID inputs
|
||||||
|
func ValidateClientID(clientID string) bool {
|
||||||
|
return validateClientIDRegex.MatchString(clientID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateCallbackURL validates callback URLs with support for wildcards
|
||||||
|
func ValidateCallbackURL(raw string) bool {
|
||||||
|
// Don't validate if it contains a wildcard
|
||||||
|
if strings.Contains(raw, "*") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
u, err := url.Parse(raw)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !u.IsAbs() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|||||||
58
backend/internal/dto/validations_test.go
Normal file
58
backend/internal/dto/validations_test.go
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestValidateUsername(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"valid simple", "user123", true},
|
||||||
|
{"valid with dot", "user.name", true},
|
||||||
|
{"valid with underscore", "user_name", true},
|
||||||
|
{"valid with hyphen", "user-name", true},
|
||||||
|
{"valid with at", "user@name", true},
|
||||||
|
{"starts with symbol", ".username", false},
|
||||||
|
{"ends with non-alphanumeric", "username-", false},
|
||||||
|
{"contains space", "user name", false},
|
||||||
|
{"empty", "", false},
|
||||||
|
{"only special chars", "-._@", false},
|
||||||
|
{"valid long", "a1234567890_b.c-d@e", true},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, ValidateUsername(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateClientID(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{"valid simple", "client123", true},
|
||||||
|
{"valid with dot", "client.id", true},
|
||||||
|
{"valid with underscore", "client_id", true},
|
||||||
|
{"valid with hyphen", "client-id", true},
|
||||||
|
{"valid with all", "client.id-123_abc", true},
|
||||||
|
{"contains space", "client id", false},
|
||||||
|
{"contains at", "client@id", false},
|
||||||
|
{"empty", "", false},
|
||||||
|
{"only special chars", "-._", true},
|
||||||
|
{"invalid char", "client!id", false},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.expected, ValidateClientID(tt.input))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type WebauthnCredentialUpdateDto struct {
|
type WebauthnCredentialUpdateDto struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=30"`
|
Name string `json:"name" binding:"required,min=1,max=50"`
|
||||||
}
|
}
|
||||||
|
|||||||
86
backend/internal/job/analytics_job.go
Normal file
86
backend/internal/job/analytics_job.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
|
||||||
|
|
||||||
|
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
|
||||||
|
// Skip if analytics are disabled or not in production environment
|
||||||
|
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send every 24 hours
|
||||||
|
jobs := &AnalyticsJob{
|
||||||
|
appConfig: appConfig,
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
type AnalyticsJob struct {
|
||||||
|
appConfig *service.AppConfigService
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendHeartbeat sends a heartbeat to the analytics service
|
||||||
|
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
|
||||||
|
// Skip if analytics are disabled or not in production environment
|
||||||
|
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(struct {
|
||||||
|
Version string `json:"version"`
|
||||||
|
InstanceID string `json:"instance_id"`
|
||||||
|
}{
|
||||||
|
Version: common.Version,
|
||||||
|
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = backoff.Retry(
|
||||||
|
parentCtx,
|
||||||
|
func() (struct{}, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := j.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return struct{}{}, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return struct{}{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return struct{}{}, nil
|
||||||
|
},
|
||||||
|
backoff.WithBackOff(backoff.NewExponentialBackOff()),
|
||||||
|
backoff.WithMaxTries(3),
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("heartbeat request failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
49
backend/internal/job/api_key_expiry_job.go
Normal file
49
backend/internal/job/api_key_expiry_job.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ApiKeyEmailJobs struct {
|
||||||
|
apiKeyService *service.ApiKeyService
|
||||||
|
appConfigService *service.AppConfigService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) error {
|
||||||
|
jobs := &ApiKeyEmailJobs{
|
||||||
|
apiKeyService: apiKeyService,
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send every day at midnight
|
||||||
|
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||||
|
// Skip if the feature is disabled
|
||||||
|
if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list expiring API keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, key := range apiKeys {
|
||||||
|
if key.User.Email == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
package job
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
func RegisterDbCleanupJobs(db *gorm.DB) {
|
|
||||||
scheduler, err := gocron.NewScheduler()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to create a new scheduler: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
jobs := &Jobs{db: db}
|
|
||||||
|
|
||||||
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
|
|
||||||
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
|
|
||||||
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
|
|
||||||
registerJob(scheduler, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens)
|
|
||||||
scheduler.Start()
|
|
||||||
}
|
|
||||||
|
|
||||||
type Jobs struct {
|
|
||||||
db *gorm.DB
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
|
||||||
func (j *Jobs) clearWebauthnSessions() error {
|
|
||||||
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
|
||||||
func (j *Jobs) clearOneTimeAccessTokens() error {
|
|
||||||
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
|
||||||
func (j *Jobs) clearOidcAuthorizationCodes() error {
|
|
||||||
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
|
||||||
func (j *Jobs) clearOidcRefreshTokens() error {
|
|
||||||
return j.db.Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
|
||||||
func (j *Jobs) clearAuditLogs() error {
|
|
||||||
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
|
|
||||||
_, err := scheduler.NewJob(
|
|
||||||
gocron.CronJob(interval, false),
|
|
||||||
gocron.NewTask(job),
|
|
||||||
gocron.WithEventListeners(
|
|
||||||
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
|
|
||||||
log.Printf("Job %q run successfully", name)
|
|
||||||
}),
|
|
||||||
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
|
|
||||||
log.Printf("Job %q failed with error: %v", name, err)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to register job %q: %v", name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
134
backend/internal/job/db_cleanup_job.go
Normal file
134
backend/internal/job/db_cleanup_job.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||||
|
jobs := &DbCleanupJobs{db: db}
|
||||||
|
|
||||||
|
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
||||||
|
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
||||||
|
return errors.Join(
|
||||||
|
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||||
|
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||||
|
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
type DbCleanupJobs struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
|
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSignupTokens deletes signup tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
|
||||||
|
// Delete tokens that are expired OR have reached their usage limit
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
|
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
|
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
|
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
79
backend/internal/job/file_cleanup_job.go
Normal file
79
backend/internal/job/file_cleanup_job.go
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||||
|
jobs := &FileCleanupJobs{db: db}
|
||||||
|
|
||||||
|
// Run every 24 hours
|
||||||
|
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
type FileCleanupJobs struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
|
||||||
|
func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) error {
|
||||||
|
var users []model.User
|
||||||
|
err := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Find(&users).
|
||||||
|
Error
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to fetch users: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map to track which initials are in use
|
||||||
|
initialsInUse := make(map[string]struct{})
|
||||||
|
for _, user := range users {
|
||||||
|
initialsInUse[user.Initials()] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultPicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults"
|
||||||
|
if _, err := os.Stat(defaultPicturesDir); os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := os.ReadDir(defaultPicturesDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read default profile pictures directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
filesDeleted := 0
|
||||||
|
for _, file := range files {
|
||||||
|
if file.IsDir() {
|
||||||
|
continue // Skip directories
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := file.Name()
|
||||||
|
initials := strings.TrimSuffix(filename, ".png")
|
||||||
|
|
||||||
|
// If these initials aren't used by any user, delete the file
|
||||||
|
if _, ok := initialsInUse[initials]; !ok {
|
||||||
|
filePath := filepath.Join(defaultPicturesDir, filename)
|
||||||
|
if err := os.Remove(filePath); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
||||||
|
} else {
|
||||||
|
filesDeleted++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
31
backend/internal/job/geoloite_update_job.go
Normal file
31
backend/internal/job/geoloite_update_job.go
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GeoLiteUpdateJobs struct {
|
||||||
|
geoLiteService *service.GeoLiteService
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteService *service.GeoLiteService) error {
|
||||||
|
// Check if the service needs periodic updating
|
||||||
|
if geoLiteService.DisableUpdater() {
|
||||||
|
// Nothing to do
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||||
|
|
||||||
|
// Run every 24 hours (and right away)
|
||||||
|
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||||
|
return j.geoLiteService.UpdateDatabase(ctx)
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
package job
|
package job
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -12,28 +14,17 @@ type LdapJobs struct {
|
|||||||
appConfigService *service.AppConfigService
|
appConfigService *service.AppConfigService
|
||||||
}
|
}
|
||||||
|
|
||||||
func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *service.AppConfigService) {
|
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
||||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||||
|
|
||||||
scheduler, err := gocron.NewScheduler()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("Failed to create a new scheduler: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Register the job to run every hour
|
// Register the job to run every hour
|
||||||
registerJob(scheduler, "SyncLdap", "0 * * * *", jobs.syncLdap)
|
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
||||||
|
|
||||||
// Run the job immediately on startup
|
|
||||||
if err := jobs.syncLdap(); err != nil {
|
|
||||||
log.Printf("Failed to sync LDAP: %s", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
scheduler.Start()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *LdapJobs) syncLdap() error {
|
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||||
if j.appConfigService.DbConfig.LdapEnabled.Value == "true" {
|
if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
|
||||||
return j.ldapService.SyncAll()
|
return nil
|
||||||
}
|
}
|
||||||
return nil
|
|
||||||
|
return j.ldapService.SyncAll(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
83
backend/internal/job/scheduler.go
Normal file
83
backend/internal/job/scheduler.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package job
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Scheduler struct {
|
||||||
|
scheduler gocron.Scheduler
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewScheduler() (*Scheduler, error) {
|
||||||
|
scheduler, err := gocron.NewScheduler()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create a new scheduler: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Scheduler{
|
||||||
|
scheduler: scheduler,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the scheduler.
|
||||||
|
// This function blocks until the context is canceled.
|
||||||
|
func (s *Scheduler) Run(ctx context.Context) error {
|
||||||
|
slog.Info("Starting job scheduler")
|
||||||
|
s.scheduler.Start()
|
||||||
|
|
||||||
|
// Block until context is canceled
|
||||||
|
<-ctx.Done()
|
||||||
|
|
||||||
|
err := s.scheduler.Shutdown()
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Error shutting down job scheduler", slog.Any("error", err))
|
||||||
|
} else {
|
||||||
|
slog.Info("Job scheduler shut down")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
|
||||||
|
jobOptions := []gocron.JobOption{
|
||||||
|
gocron.WithContext(ctx),
|
||||||
|
gocron.WithEventListeners(
|
||||||
|
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||||
|
slog.Info("Starting job",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.String("id", jobID.String()),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||||
|
slog.Info("Job run successfully",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.String("id", jobID.String()),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
|
||||||
|
slog.Error("Job failed with error",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.String("id", jobID.String()),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
if runImmediately {
|
||||||
|
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to register job %q: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
|||||||
userID, isAdmin, err := m.Verify(c, adminRequired)
|
userID, isAdmin, err := m.Verify(c, adminRequired)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.Abort()
|
c.Abort()
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,12 +36,15 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
|||||||
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
|
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
|
||||||
apiKey := c.GetHeader("X-API-KEY")
|
apiKey := c.GetHeader("X-API-KEY")
|
||||||
|
|
||||||
user, err := m.apiKeyService.ValidateApiKey(apiKey)
|
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false, &common.NotSignedInError{}
|
return "", false, &common.NotSignedInError{}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if the user is an admin
|
if user.Disabled {
|
||||||
|
return "", false, &common.UserDisabledError{}
|
||||||
|
}
|
||||||
|
|
||||||
if adminRequired && !user.IsAdmin {
|
if adminRequired && !user.IsAdmin {
|
||||||
return "", false, &common.MissingPermissionError{}
|
return "", false, &common.MissingPermissionError{}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"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/service"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -19,11 +22,12 @@ type AuthOptions struct {
|
|||||||
|
|
||||||
func NewAuthMiddleware(
|
func NewAuthMiddleware(
|
||||||
apiKeyService *service.ApiKeyService,
|
apiKeyService *service.ApiKeyService,
|
||||||
|
userService *service.UserService,
|
||||||
jwtService *service.JwtService,
|
jwtService *service.JwtService,
|
||||||
) *AuthMiddleware {
|
) *AuthMiddleware {
|
||||||
return &AuthMiddleware{
|
return &AuthMiddleware{
|
||||||
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
|
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
|
||||||
jwtMiddleware: NewJwtAuthMiddleware(jwtService),
|
jwtMiddleware: NewJwtAuthMiddleware(jwtService, userService),
|
||||||
options: AuthOptions{
|
options: AuthOptions{
|
||||||
AdminRequired: true,
|
AdminRequired: true,
|
||||||
SuccessOptional: false,
|
SuccessOptional: false,
|
||||||
@@ -57,22 +61,32 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
|
|||||||
|
|
||||||
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// First try JWT auth
|
|
||||||
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// JWT auth succeeded, continue with the request
|
|
||||||
c.Set("userID", userID)
|
c.Set("userID", userID)
|
||||||
c.Set("userIsAdmin", isAdmin)
|
c.Set("userIsAdmin", isAdmin)
|
||||||
|
if c.IsAborted() {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If JWT auth failed and the error is not a NotSignedInError, abort the request
|
||||||
|
if !errors.Is(err, &common.NotSignedInError{}) {
|
||||||
|
c.Abort()
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// JWT auth failed, try API key auth
|
// JWT auth failed, try API key auth
|
||||||
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
// API key auth succeeded, continue with the request
|
|
||||||
c.Set("userID", userID)
|
c.Set("userID", userID)
|
||||||
c.Set("userIsAdmin", isAdmin)
|
c.Set("userIsAdmin", isAdmin)
|
||||||
|
if c.IsAborted() {
|
||||||
|
return
|
||||||
|
}
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -84,6 +98,6 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
|||||||
|
|
||||||
// Both JWT and API key auth failed
|
// Both JWT and API key auth failed
|
||||||
c.Abort()
|
c.Abort()
|
||||||
c.Error(err)
|
_ = c.Error(err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
package middleware
|
package middleware
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type CorsMiddleware struct{}
|
type CorsMiddleware struct{}
|
||||||
@@ -13,17 +14,23 @@ func NewCorsMiddleware() *CorsMiddleware {
|
|||||||
|
|
||||||
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
// Allow all origins for the token endpoint
|
path := c.FullPath()
|
||||||
if c.FullPath() == "/api/oidc/token" {
|
if path == "" {
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
// The router doesn't map preflight requests, so we need to use the raw URL path
|
||||||
} else {
|
path = c.Request.URL.Path
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
|
if !isCorsPath(path) {
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
|
c.Next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
|
||||||
|
|
||||||
|
// Preflight request
|
||||||
|
if c.Request.Method == http.MethodOptions {
|
||||||
c.AbortWithStatus(204)
|
c.AbortWithStatus(204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -31,3 +38,17 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
|||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isCorsPath(path string) bool {
|
||||||
|
switch path {
|
||||||
|
case "/api/oidc/token",
|
||||||
|
"/api/oidc/userinfo",
|
||||||
|
"/oidc/end-session",
|
||||||
|
"/api/oidc/introspect",
|
||||||
|
"/.well-known/jwks.json",
|
||||||
|
"/.well-known/openid-configuration":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user