Compare commits

...

290 Commits

Author SHA1 Message Date
Elias Schneider
4b086cebcd release: 1.9.0 2025-08-24 20:54:03 +02:00
Alessandro (Ale) Segala
1f3550c9bd fix: ensure SQLite has a writable temporary directory (#876)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:51 +02:00
dependabot[bot]
912008b048 chore(deps): bump golang.org/x/oauth2 from 0.26.0 to 0.27.0 in /backend in the go_modules group across 1 directory (#879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:30 +02:00
Elias Schneider
5ad8b03831 chore(translations): update translations via Crowdin (#878) 2025-08-24 20:42:58 +02:00
Elias Schneider
c1e515a05f ci/cd: use matrix for e2e tests 2025-08-24 20:35:30 +02:00
Elias Schneider
654593b4b6 chore(migrations): use TEXT instead of VARCHAR for client ID 2025-08-24 20:22:06 +02:00
Elias Schneider
8999173aa0 ci/cd: fix playwright browsers not installed 2025-08-24 20:16:57 +02:00
Elias Schneider
10b087640f tests: fix postgres e2e tests (#877) 2025-08-24 19:15:26 +02:00
Elias Schneider
d0392d25ed fix: sort order incorrect for apps when using postgres 2025-08-24 19:08:33 +02:00
Elias Schneider
2ffc6ba42a fix: don't force uuid for client id in postgres 2025-08-24 18:29:41 +02:00
Elias Schneider
c114a2edaa feat: support automatic db migration rollbacks (#874) 2025-08-24 16:56:28 +02:00
Elias Schneider
63db4d5120 chore(migrations): add postgres down migration to 20250822000000 2025-08-24 15:30:18 +02:00
Elias Schneider
d8c73ed472 release: 1.8.1 2025-08-24 15:12:14 +02:00
Elias Schneider
5971bfbfa6 fix: migration clears allowed users groups 2025-08-24 15:05:45 +02:00
Alessandro (Ale) Segala
29eacd6424 chore: update issue template (#870) 2025-08-24 14:35:39 +02:00
Elias Schneider
21ca87be38 chore(translations): update translations via Crowdin (#860) 2025-08-24 14:34:44 +02:00
Alessandro (Ale) Segala
1283314f77 fix: wrong column type for reauthentication tokens in Postgres (#869) 2025-08-24 14:34:29 +02:00
Elias Schneider
9c54e2e6b0 release: 1.8.0 2025-08-23 18:57:19 +02:00
Elias Schneider
a5efb95065 feat: allow custom client IDs (#864) 2025-08-23 18:41:05 +02:00
Elias Schneider
625f235740 fix: enable foreign key check for sqlite (#863)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-08-23 17:54:51 +02:00
Elias Schneider
2c122d413d refactor: run formatter 2025-08-23 17:46:59 +02:00
Elias Schneider
fc0c99a232 fix: oidc client advanced options color 2025-08-23 17:40:58 +02:00
Elias Schneider
24e274200f fix: ferated identities can't be cleared 2025-08-23 17:40:06 +02:00
Elias Schneider
0aab3f3c7a fix: authorization can't be revoked 2025-08-23 17:28:27 +02:00
Zeedif
182d809028 feat(signup): add default user groups and claims for new users (#812)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 14:25:02 +02:00
Elias Schneider
c51265dafb chore(translations): change alternative sign in methods text 2025-08-22 13:06:38 +02:00
Robert Mang
0cb039d35d feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 08:56:40 +02:00
Alessandro (Ale) Segala
7ab0fd3028 fix: for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date (#855) 2025-08-22 08:02:56 +02:00
Maxime R
49f0fa423c chore: strip debug symbol from backend binary (#856) 2025-08-21 15:46:45 +00:00
Elias Schneider
61e63e411d chore(translations): update translations via Crowdin (#850) 2025-08-20 17:07:08 -05:00
Alessandro (Ale) Segala
9339e88a5a fix: move audit log call before TX is committed (#854) 2025-08-20 17:01:53 -05:00
Elias Schneider
fe003b927c fix: delete webauthn session after login to prevent replay attacks 2025-08-20 15:49:19 +02:00
Kyle Mendell
f5b5b1bd85 tests: use proper async calls for cleanupBackend function (#846) 2025-08-20 10:38:03 +02:00
James18232
d28bfac81f feat: login code font change (#851)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-19 14:10:57 +00:00
Elias Schneider
b04e3e8ecf chore(translations): update translations via Crowdin (#848) 2025-08-19 12:03:51 +02:00
Kyle Mendell
d77d8eb068 chore(translations): add Korean files 2025-08-18 14:53:19 -05:00
Elias Schneider
7cd88aca25 chore(translations): update translations via Crowdin (#841) 2025-08-18 11:21:27 -05:00
Gergő Gutyina
b5e6371eaa fix(deps): bump rollup from 4.45.3 to 4.46.3 (#845) 2025-08-18 07:44:42 -05:00
github-actions[bot]
544b98c1d0 chore: update AAGUIDs (#844)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-17 22:52:58 -05:00
Elias Schneider
3188e92257 feat: display all accessible oidc clients in the dashboard (#832)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-17 22:47:34 +02:00
Elias Schneider
3fa2f9a162 chore(translations): update translations via Crowdin (#821) 2025-08-16 22:50:21 -05:00
James18232
7b1f6b8857 fix: ignore client secret if client is public (#836)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
2025-08-16 17:55:32 +02:00
Alessandro (Ale) Segala
17d8893bdb chore: update deps and Go 1.25 (#833) 2025-08-14 22:33:27 -05:00
Elias Schneider
0e44f245af fix: non admin users can't revoke oidc client but see edit link 2025-08-12 09:46:15 +02:00
github-actions[bot]
824e8f1a0f chore: update AAGUIDs (#826)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-10 21:33:29 -05:00
Elias Schneider
6e4d2a4a33 release: 1.7.0 2025-08-10 20:01:03 +02:00
Elias Schneider
6c65bd34cd chore(translations): update translations via Crowdin (#820) 2025-08-10 19:50:36 +02:00
Kyle Mendell
7bfe4834d0 chore: switch from npm to pnpm (#786)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 12:16:30 -05:00
Kyle Mendell
484c2f6ef2 feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 15:56:03 +00:00
Elias Schneider
87956ea725 chore(translations): update translations via Crowdin (#819) 2025-08-10 10:18:30 -05:00
Elias Schneider
32dd403038 chore(translations): update translations via Crowdin (#817) 2025-08-10 14:49:24 +02:00
Elias Schneider
4d59e72866 fix: custom claims input suggestions instantly close after opening 2025-08-08 15:11:44 +02:00
Elias Schneider
9ac5d51187 fix: authorization animation not working 2025-08-08 12:23:32 +02:00
Elias Schneider
5a031f5d1b refactor: use reflection to mark file based env variables (#815) 2025-08-07 20:41:00 +02:00
Alessandro (Ale) Segala
535bc9f46b chore: additional logs for database connections (#813) 2025-08-06 18:04:25 +02:00
Kyle Mendell
f0c144c51c fix: admins can not delete or disable their own account 2025-08-05 16:14:25 -05:00
Elias Schneider
61e4ea45fb chore(translations): update translations via Crowdin (#811) 2025-08-05 15:56:45 -05:00
Etienne
06e1656923 feat: add robots.txt to block indexing (#806) 2025-08-02 18:30:50 +00:00
Alessandro (Ale) Segala
0a3b1c6530 feat: support reading secret env vars from _FILE (#799)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-07-30 11:59:25 -05:00
Kyle Mendell
d479817b6a feat: add support for code_challenge_methods_supported (#794) 2025-07-29 17:34:49 -05:00
Elias Schneider
01bf31d23d chore(translations): update translations via Crowdin (#791) 2025-07-27 20:21:37 -05:00
Alessandro (Ale) Segala
42a861d206 refactor: complete conversion of log calls to slog (#787) 2025-07-27 04:34:23 +00:00
Alessandro (Ale) Segala
78266e3e4c feat: Support OTel and JSON for logs (via log/slog) (#760)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-27 01:03:52 +00:00
Alessandro (Ale) Segala
c8478d75be fix: delete WebAuthn registration session after use (#783) 2025-07-26 18:45:54 -05:00
Elias Schneider
28d93b00a3 chore(translations): update translations via Crowdin (#785) 2025-07-26 16:37:42 -05:00
Kyle Mendell
12a7a6a5c5 chore: update Vietnamese display name 2025-07-26 15:33:36 -05:00
Elias Schneider
a6d5071724 chore(translations): update translations via Crowdin (#782) 2025-07-25 15:48:52 -05:00
Elias Schneider
cebe2242b9 chore(translations): update translations via Crowdin (#779) 2025-07-24 20:28:07 -05:00
Kyle Mendell
56ee7d946f chore: fix federated credentials type error 2025-07-24 20:22:34 -05:00
Kyle Mendell
f3c6521f2b chore: update dependencies and fix zod/4 import path 2025-07-24 20:16:17 -05:00
Kyle Mendell
ffed465f09 chore: update dependencies and fix zod/4 import path 2025-07-24 20:14:25 -05:00
Kyle Mendell
c359b5be06 chore: rename glass-row-item to passkey-row 2025-07-24 19:50:27 -05:00
Elias Schneider
e9a023bb71 chore(translations): update translations via Crowdin (#778)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-24 19:35:16 -05:00
Kyle Mendell
60f0b28076 chore(transaltions): add Vietnamese files 2025-07-24 10:11:01 -05:00
Alessandro (Ale) Segala
d541c9ab4a fix: set input type 'email' for email-based login (#776) 2025-07-23 12:39:50 -05:00
dependabot[bot]
024ed53022 chore(deps): bump axios from 1.10.0 to 1.11.0 in /frontend in the npm_and_yarn group across 1 directory (#777)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-23 12:38:00 -05:00
Elias Schneider
2c78bd1b46 chore(translations): update translations via Crowdin (#767) 2025-07-22 15:08:04 -05:00
dependabot[bot]
5602d79611 chore(deps): bump form-data from 4.0.1 to 4.0.4 in /frontend in the npm_and_yarn group across 1 directory (#771)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 15:07:43 -05:00
Kyle Mendell
51b73c9c31 chore(translations): add Ukrainian files 2025-07-21 15:56:59 -05:00
Elias Schneider
10f0580a43 chore(translations): update translations via Crowdin (#763) 2025-07-21 07:32:57 -05:00
ItalyPaleAle
a1488565ea release: 1.6.4 2025-07-21 07:44:25 +02:00
Alessandro (Ale) Segala
35d5f887ce fix: migration fails on postgres (#762) 2025-07-20 22:36:22 -07:00
Kyle Mendell
4c76de45ed chore: remove labels from issue templates 2025-07-20 22:51:02 -05:00
Kyle Mendell
68fc9c0659 release: 1.6.3 2025-07-20 22:35:35 -05:00
Kyle Mendell
2952b15755 fix: show rename and delete buttons for passkeys without hovering over the row 2025-07-20 19:09:06 -05:00
Kyle Mendell
ef1d599662 fix: use user-agent for identifying known device signins 2025-07-20 19:02:17 -05:00
Kyle Mendell
4e49d3932a chore: upgrade dependencies (#752) 2025-07-14 23:36:36 -05:00
Elias Schneider
86d3c08494 chore(translations): update translations via Crowdin (#750) 2025-07-14 13:15:33 -05:00
Alessandro (Ale) Segala
7b4ccd1f30 fix: ensure user inputs are normalized (#724)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-07-13 16:15:57 +00:00
Kyle Mendell
f145903eb0 chore: use correct svelte 5 syntax for signup token modal 2025-07-11 22:53:01 -05:00
Kyle Mendell
d3bc1797b6 fix: use object-contain for images on oidc-client list 2025-07-11 22:46:40 -05:00
Kyle Mendell
db94f81937 chore: use issue types for new issues 2025-07-11 22:25:11 -05:00
Kyle Mendell
b03e91b653 fix: allow passkey names up to 50 characters 2025-07-11 22:10:59 -05:00
Kyle Mendell
505bdcb8ba release: 1.6.2 2025-07-09 16:56:34 -05:00
Kyle Mendell
f103a54790 fix: ensure confirmation dialog shows on top of other components 2025-07-09 16:50:01 -05:00
Alessandro (Ale) Segala
e1de593dcd fix: login failures on Postgres when IP is null (#737) 2025-07-09 08:45:07 -05:00
Elias Schneider
45f42772b1 chore(translations): update translations via Crowdin (#730) 2025-07-07 20:06:52 -05:00
XLion
98152640b1 chore(translations): Fix inconsistent punctuation marks for the language name of zh-TW (#731) 2025-07-07 12:54:45 +00:00
github-actions[bot]
04e235e805 chore: update AAGUIDs (#729)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-07-06 21:04:32 -05:00
Elias Schneider
ae737dddaa release: 1.6.1 2025-07-06 22:50:33 +02:00
Elias Schneider
f565c702e5 ci/cd: use latest-distroless tag for latest distroless images 2025-07-06 22:48:55 +02:00
Elias Schneider
f945b44bc9 release: 1.6.0 2025-07-06 20:19:45 +02:00
Elias Schneider
857b9cc864 refactor: run formatter 2025-07-06 15:32:19 +02:00
Elias Schneider
bf042563e9 feat: add support for OAuth 2.0 Authorization Server Issuer Identification 2025-07-06 15:29:26 +02:00
Elias Schneider
49f1ab2f75 fix: custom claims input suggestions flickering 2025-07-06 00:23:06 +02:00
Elias Schneider
e46f60ac8d fix: keep sidebar in settings sticky 2025-07-05 21:59:13 +02:00
Elias Schneider
5c9e504291 fix: show friendly name in user group selection 2025-07-05 21:58:56 +02:00
Alessandro (Ale) Segala
7fe83f8087 fix: actually fix linter issues (#720) 2025-07-04 21:14:44 -05:00
Alessandro (Ale) Segala
43f0114c57 fix: linter issues (#719) 2025-07-04 18:29:28 -05:00
Alessandro (Ale) Segala
1a41b05f60 feat: distroless container additional variant + healthcheck command (#716) 2025-07-04 12:26:01 -07:00
Elias Schneider
81315790a8 fix: support non UTF-8 LDAP IDs (#714) 2025-07-04 08:42:11 +02:00
Alessandro (Ale) Segala
8c8fc2304d feat: add "key-rotate" command (#709) 2025-07-03 22:23:24 +02:00
Elias Schneider
15ece0ab30 chore(translations): update translations via Crowdin (#712) 2025-07-03 13:47:27 -05:00
Alessandro (Ale) Segala
5550729120 feat: encrypt private keys saved on disk and in database (#682)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-03 13:34:34 -05:00
Elias Schneider
9872608d61 fix: allow profile picture update even if "allow own account edit" enabled 2025-07-03 10:57:56 +02:00
Elias Schneider
be52660227 feat: enhance language selection message and add translation contribution link 2025-07-03 09:20:39 +02:00
Elias Schneider
237342e876 chore(translations): update translations via Crowdin (#707) 2025-07-02 13:45:10 +02:00
Elias Schneider
cfbfbc9753 chore(translations): update translations via Crowdin (#705) 2025-07-01 17:11:42 -05:00
Elias Schneider
aefb308536 fix: token introspection authentication not handled correctly (#704)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-07-01 21:14:07 +00:00
Alessandro (Ale) Segala
031181ad2a fix: auth fails when client IP is empty on Postgres (#695) 2025-06-30 14:04:30 +02:00
Elias Schneider
dbf3da41f3 chore(translations): update translations via Crowdin (#699)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-06-29 13:04:15 -05:00
Kyle Mendell
3a2902789e chore: use correct team name for codeowners 2025-06-29 09:31:32 -05:00
Kyle Mendell
459a4fd727 chore: update CODEOWNERS to be global 2025-06-29 09:30:00 -05:00
Kyle Mendell
2ecc1abbad chore: add CODEOWNERS file 2025-06-29 09:28:08 -05:00
Kyle Mendell
92c57ada1a fix: app config forms not updating with latest values (#696) 2025-06-29 15:13:06 +02:00
Elias Schneider
fceb6fa7b4 fix: add missing error check in initial user setup 2025-06-29 15:10:39 +02:00
Alessandro (Ale) Segala
c290c027fb refactor: use github.com/jinzhu/copier for MapStruct (#698) 2025-06-29 15:01:10 +02:00
Elias Schneider
ca205a8c73 chore(translations): update translations via Crowdin (#697) 2025-06-29 01:01:39 -05:00
Elias Schneider
968cf0b307 chore(translations): update translations via Crowdin (#694) 2025-06-28 21:25:58 -05:00
Elias Schneider
fd8bee94a4 chore(translations): update translations via Crowdin (#692) 2025-06-28 15:26:17 +02:00
Manuel Rais
41ac1be082 chore(translations) : translate missing french values (#691) 2025-06-28 15:26:05 +02:00
Elias Schneider
dd9b1d26ea release: 1.5.0 2025-06-27 23:56:16 +02:00
Elias Schneider
4b829757b2 tests: fix e2e tests 2025-06-27 23:52:43 +02:00
Elias Schneider
b5b01cb6dd chore(translations): update translations via Crowdin (#688) 2025-06-27 23:42:32 +02:00
Elias Schneider
287314f016 feat: improve initial admin creation workflow 2025-06-27 23:41:05 +02:00
Elias Schneider
73e7e0b1c5 refactor: add formatter to Playwright tests 2025-06-27 23:33:26 +02:00
Elias Schneider
d070b9a778 fix: double double full stops for certain error messages 2025-06-27 22:43:31 +02:00
Elias Schneider
d976bf5965 fix: improve accent color picker disabled state 2025-06-27 22:38:21 +02:00
Elias Schneider
052ac008c3 fix: margin of user sign up description 2025-06-27 22:31:55 +02:00
Elias Schneider
57a2b2bc83 chore(translations): update translations via Crowdin (#687) 2025-06-27 22:24:36 +02:00
ElevenNotes
043f82ad79 fix: less noisy logging for certain GET requests (#681)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-27 22:24:22 +02:00
Elias Schneider
ba61cdba4e feat: redact sensitive app config variables if set with env variable 2025-06-27 22:22:28 +02:00
Kyle Mendell
dcd1ae96e0 feat: self-service user signup (#672)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-27 15:01:10 -05:00
Elias Schneider
1fdb058386 docs: clarify confusing user update logic 2025-06-27 17:20:51 +02:00
Elias Schneider
29cb5513a0 fix: users can't be updated by admin if self account editing is disabled 2025-06-27 17:15:26 +02:00
Elias Schneider
6db57d9f27 chore(translations): update translations via Crowdin (#683) 2025-06-26 19:01:16 +02:00
Elias Schneider
1a77bd9914 fix: error page flickering after sign out 2025-06-24 21:56:40 +02:00
Elias Schneider
350335711b chore(translations): update translations via Crowdin (#677) 2025-06-24 09:00:57 -05:00
Ryan Kaskel
988c425150 fix: remove duplicate request logging (#678) 2025-06-24 13:48:11 +00:00
Elias Schneider
23827ba1d1 release: 1.4.1 2025-06-22 21:30:07 +02:00
Elias Schneider
7d36bda769 fix: app not starting if UI config is disabled and Postgres is used 2025-06-22 21:21:14 +02:00
Manuel Rais
8c559ea067 chore(translations) : typo in french language (#669) 2025-06-22 18:58:59 +00:00
Elias Schneider
88832d4bc9 chore(translations): update translations via Crowdin (#663) 2025-06-20 11:11:42 +02:00
Kyle Mendell
f5cece3b0e release: 1.4.0 2025-06-19 13:21:49 -05:00
Kyle Mendell
d5485238b8 feat: configurable local ipv6 ranges for audit log (#657) 2025-06-19 19:56:27 +02:00
Kyle Mendell
ac5a121f66 feat: location filter for global audit log (#662) 2025-06-19 17:12:53 +00:00
Elias Schneider
481df3bcb9 chore: add configuration for backend hot reloading 2025-06-19 18:45:01 +02:00
Mr Snake
7677a3de2c feat: allow setting unix socket mode (#661) 2025-06-18 18:41:57 +02:00
Elias Schneider
1f65c01b04 chore(translations): update translations via Crowdin (#659) 2025-06-18 09:11:36 -05:00
Elias Schneider
d5928f6fea chore: remove unused crypto util 2025-06-17 17:55:52 +02:00
Elias Schneider
bef77ac8dc fix: use inline style for dynamic background image URL instead of Tailwind class 2025-06-17 13:10:02 +02:00
Elias Schneider
c8eb034c49 chore: use v1 tag in example docker-compose.yml 2025-06-16 23:31:16 +02:00
Elias Schneider
c77167df46 ci/cd: cancel build-next action if new one starts 2025-06-16 16:12:33 +02:00
Elias Schneider
3717a663d9 ci/cd: only build required binaries for next image 2025-06-16 16:09:09 +02:00
Elias Schneider
5814549cbe refactor: run formatter 2025-06-16 16:06:11 +02:00
Elias Schneider
2e5d268798 fix: explicitly cache images to prevent unexpected behavior 2025-06-16 15:59:14 +02:00
Elias Schneider
4ed312251e chore(translations): update translations via Crowdin (#652) 2025-06-15 09:57:08 -05:00
Elias Schneider
946c534b08 fix: center oidc client images if they are smaller than the box 2025-06-14 19:33:40 +02:00
Kyle Mendell
883877adec feat: ui accent colors (#643)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-13 07:06:54 -05:00
Elias Schneider
215531d65c feat: use icon instead of text on application image update hover state 2025-06-13 12:07:14 +02:00
Elias Schneider
c0f055c3c0 chore(translations): update translations via Crowdin (#649) 2025-06-10 22:01:50 -05:00
Alessandro (Ale) Segala
d77044882d fix: reduce duration of animations on login and signin page (#648) 2025-06-10 21:14:55 +02:00
Alessandro (Ale) Segala
d6795300b1 feat: auto-focus on the login buttons (#647) 2025-06-10 21:13:36 +02:00
Elias Schneider
fd3c76ffa3 refactor: run formatter 2025-06-10 14:43:56 +02:00
Amazingca
698bc3a35a chore(translations): Update spelling and grammar in en.json (#650) 2025-06-10 07:34:49 -05:00
Elias Schneider
1bcb50edc3 fix: allow images with uppercase file extension 2025-06-10 11:11:03 +02:00
Elias Schneider
9700afb9cb chore(translations): update translations via Crowdin (#644) 2025-06-10 10:36:52 +02:00
Elias Schneider
9ce82fb205 release: 1.3.1 2025-06-09 22:59:13 +02:00
Elias Schneider
2935236ace fix: change timestamp of client_credentials.sql migration 2025-06-09 22:58:56 +02:00
Elias Schneider
c821b675b8 release: 1.3.0 2025-06-09 21:37:27 +02:00
Elias Schneider
a09d529027 chore: add branch check to release script 2025-06-09 21:37:00 +02:00
Alessandro (Ale) Segala
b62b61fb01 feat: allow introspection and device code endpoints to use Federated Client Credentials (#640) 2025-06-09 21:17:55 +02:00
Alessandro (Ale) Segala
df5c1ed1f8 chore: add docs link and rename to Federated Client Credentials (#636)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 21:15:37 +02:00
Elias Schneider
f4af35f86b chore(translations): update translations via Crowdin (#642) 2025-06-09 18:36:37 +02:00
Elias Schneider
657a51f7ed fix: misleading text for disable animations option 2025-06-09 18:22:55 +02:00
Elias Schneider
575b2f71e9 fix: use full width for audit log filters 2025-06-09 18:16:53 +02:00
Elias Schneider
97f7326da4 feat: new color theme for the UI 2025-06-09 18:09:13 +02:00
Elias Schneider
242d87a54b chore: upgrade to Shadcn v1.0.0 2025-06-09 18:08:39 +02:00
Kyle Mendell
c111b79147 feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 15:46:03 +00:00
Elias Schneider
61bf14225b chore(translations): update translations via Crowdin (#637) 2025-06-09 11:58:14 +02:00
github-actions[bot]
c1e98411b6 chore: update AAGUIDs (#639)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-06-09 11:48:21 +02:00
Elias Schneider
b25e95fc4a ci/cd: add missing attestions permission 2025-06-08 16:15:23 +02:00
Elias Schneider
3cc82d8522 docs: remove difficult to maintain OpenAPI properties 2025-06-08 16:10:42 +02:00
Elias Schneider
ea4e48680c docs: fix pagination API docs 2025-06-08 16:04:58 +02:00
Elias Schneider
f403eed12c ci/cd: add missing permission 2025-06-08 16:03:40 +02:00
Elias Schneider
388a874922 refactor: upgrade to Zod v4 (#623) 2025-06-08 15:44:59 +02:00
Elias Schneider
9a4aab465a chore(translations): update translations via Crowdin (#632) 2025-06-08 15:44:22 +02:00
Kyle Mendell
a052cd6619 ci/cd: add workflow for building 'next' docker image (#633)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-08 15:42:41 +02:00
Elias Schneider
31a803b243 chore(translations): add Traditional Chinese files 2025-06-07 21:04:48 +02:00
Elias Schneider
1d2e41c04e chore(translations): update translations via Crowdin (#629) 2025-06-07 21:01:49 +02:00
Elias Schneider
b650d6d423 chore(translations): add Danish language files 2025-06-06 16:27:33 +02:00
Elias Schneider
156aad3057 chore(translations): update translations via Crowdin (#620) 2025-06-06 16:25:37 +02:00
Alessandro (Ale) Segala
05bfe00924 feat: JWT bearer assertions for client authentication (#566)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-06 12:23:51 +02:00
Mr Snake
035b2c022b feat: add unix socket support (#615) 2025-06-06 07:01:19 +00:00
Elias Schneider
61b62d4612 fix: OIDC client image can't be deleted 2025-06-06 08:50:33 +02:00
Elias Schneider
dc5d7bb2f3 refactor: run fomratter 2025-06-05 22:43:24 +02:00
Elias Schneider
5e9096e328 fix: UI config overridden by env variables don't apply on first start 2025-06-05 22:36:55 +02:00
Elias Schneider
34b4ba514f chore(translations): update translations via Crowdin (#614) 2025-06-05 15:42:28 +02:00
Elias Schneider
d217083059 feat: add API endpoint for user authorized clients 2025-06-04 09:23:44 +02:00
Elias Schneider
bdcef60cab fix: don't load app config and user on every route change 2025-06-04 08:52:34 +02:00
Elias Schneider
14f59ce3f3 release: 1.2.0 2025-06-03 22:33:40 +02:00
Elias Schneider
31ad904367 fix: page scrolls up on form submisssion 2025-06-03 21:12:21 +02:00
Elias Schneider
04fcf1110e fix: improve spacing on auth screens 2025-06-03 21:09:32 +02:00
Elias Schneider
eb9b6433ae chore(translations): update translations via Crowdin (#606) 2025-06-02 15:58:52 +02:00
Elias Schneider
b9489b5e9a fix: whitelist authorization header for CORS 2025-06-02 15:55:29 +02:00
Elias Schneider
bd1c69b7b7 Update Crowdin configuration file 2025-06-02 14:17:21 +02:00
Elias Schneider
23dc235bac Update Crowdin configuration file 2025-06-02 14:13:16 +02:00
Elias Schneider
2440379cd1 fix: fallback to primary language if no translation available for specific country 2025-06-02 14:08:32 +02:00
Elias Schneider
6c00aaa3ef fix: allow users to update their locale even when own account update disabled 2025-06-02 11:35:13 +02:00
Elias Schneider
00259f8819 tests: adapt unit test for new app config default value behavior 2025-06-01 20:54:53 +02:00
Elias Schneider
decf8ec70b fix: clear default app config variables from database 2025-06-01 20:46:44 +02:00
Elias Schneider
c24a5546a5 docs: use https in .env.example 2025-05-31 20:51:55 +02:00
Elias Schneider
312421d777 chore(translations): update translations via Crowdin (#599) 2025-05-31 18:44:34 +02:00
Elias Schneider
c42a29a66c chore(translations): update translations via Crowdin (#593) 2025-05-30 21:56:28 -05:00
Elias Schneider
afc317adf7 chore(translations): update translations via Crowdin (#590) 2025-05-29 23:47:58 +02:00
Alessandro (Ale) Segala
256f74d0a3 fix: don't use TOFU for logout callback URLs (#588) 2025-05-29 22:01:23 +02:00
Kyle Mendell
20d3f780a2 feat: auto detect callback url (#583)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-29 17:16:10 +02:00
Alessandro (Ale) Segala
6d6dc6646a fix: run jobs at interval instead of specific time (#585) 2025-05-29 17:15:35 +02:00
Alessandro (Ale) Segala
3d402fc0ca fix: small fixes in analytics_job (#582) 2025-05-28 11:12:44 -05:00
Kyle Mendell
b874681824 fix: show LAN for auditlog location for internal networks 2025-05-28 10:52:40 -05:00
Elias Schneider
97cbdfb1ef chore(translations): update translations via Crowdin (#579) 2025-05-28 10:21:03 -05:00
Elias Schneider
24889f9ebc release: 1.1.0 2025-05-28 11:30:09 +02:00
Elias Schneider
e0ec607198 feat: add daily heartbeat request for counting Pocket ID instances (#578) 2025-05-28 11:19:45 +02:00
Maxim Baz
d29fca155e ci/cd: tag container images with v{major} (#577) 2025-05-27 14:01:49 +02:00
Elias Schneider
e2e26b53b3 chore(translations): update translations via Crowdin (#575) 2025-05-26 11:07:40 +02:00
github-actions[bot]
948efbd9c1 chore: update AAGUIDs (#576)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-26 11:06:41 +02:00
Elias Schneider
f03b80f9d7 fix: run user group count inside a transaction 2025-05-25 22:24:28 +02:00
Kyle Mendell
38d7ee4432 feat: show allowed group count on oidc client list (#567)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 19:22:25 +00:00
Kyle Mendell
f66e8e8b44 fix: use ldapAttributeUserUsername for finding group members (#565)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 20:37:17 +02:00
Elias Schneider
ee133dbceb chore(translations): update translations via Crowdin (#573) 2025-05-25 20:14:46 +02:00
Elias Schneider
68e4b67bd2 feat: require user verification for passkey sign in 2025-05-25 17:09:05 +02:00
Elias Schneider
553147a1c0 release: 1.0.0 2025-05-25 00:06:40 +02:00
Elias Schneider
31ae8cac96 ci/cd: fix subject digest in container image attestation 2025-05-25 00:06:21 +02:00
Elias Schneider
7691622274 chore: remove default value from TARGETARCH in Dockerfile 2025-05-24 23:49:07 +02:00
Elias Schneider
5d1be367c3 chore(translations): update translations via Crowdin (#561) 2025-05-24 23:24:06 +02:00
Elias Schneider
ed0e566e99 ci/cd: upgrade build-push-action 2025-05-24 23:23:48 +02:00
Elias Schneider
2793eb4ebd chore: add major flag to release script 2025-05-24 23:00:33 +02:00
Elias Schneider
059073d4c2 fix: trim whitespaces from string inputs 2025-05-24 22:55:46 +02:00
Elias Schneider
e19b33fc2e fix: use same color as title for description in alert 2025-05-24 22:55:46 +02:00
Elias Schneider
cbe7aa6eec docs: adapt contribution guide 2025-05-24 22:55:46 +02:00
Elias Schneider
2a457ac8e9 chore: remove unused data.json 2025-05-24 22:55:46 +02:00
Elias Schneider
0d4d5386c7 chore: exclude binary from project root 2025-05-24 22:55:46 +02:00
Elias Schneider
f820fc8301 fix: use pointer cursor for menu items 2025-05-24 22:55:46 +02:00
Elias Schneider
131f470757 fix: show correct app name on sign out page 2025-05-24 22:55:46 +02:00
Elias Schneider
6c35570e78 fix: add back month and year selection for date picker 2025-05-24 22:55:46 +02:00
Elias Schneider
869c4c5871 tests: fix e2e tests after shadcn upgrade 2025-05-24 22:55:46 +02:00
Elias Schneider
a65c0b3da3 chore: add missing types to Playwright tests 2025-05-24 22:55:46 +02:00
Elias Schneider
3042de2ce1 tests: fix lldap setup if data already seeded 2025-05-24 22:55:46 +02:00
Elias Schneider
f57c8d347e fix: remove nested button in user group list 2025-05-24 22:55:46 +02:00
Elias Schneider
5b3ff7b879 tests: fix change locale test 2025-05-24 22:55:46 +02:00
Elias Schneider
9fff6ec3b6 tests: move auth.setup.ts into specs folder 2025-05-24 22:55:46 +02:00
Elias Schneider
ca5e754aea ci/cd: fix .auth path of e2e tests 2025-05-24 22:55:46 +02:00
Elias Schneider
ebcf861aa6 ci/cd: start test containers with Docker Compose 2025-05-24 22:55:46 +02:00
Elias Schneider
966a566ade refactor: move e2e tests to root of repository 2025-05-24 22:55:46 +02:00
Elias Schneider
5fa15f6098 fix: remove curly bracket from user group URL 2025-05-24 22:55:46 +02:00
Kyle Mendell
53f212fd3a tests: wait for network 2025-05-24 22:55:46 +02:00
Kyle Mendell
21cb3310d6 tests: use bits-10 as selector 2025-05-24 22:55:46 +02:00
Kyle Mendell
4dc0b2f37f fix: ldap tests 2025-05-24 22:55:46 +02:00
Elias Schneider
ac6df536ef tests: adapt e2e tests 2025-05-24 22:55:46 +02:00
Elias Schneider
c37386f8b2 feat: improve buttons styling 2025-05-24 22:55:46 +02:00
Elias Schneider
c3a03db8b0 fix: authorize page doesn't load 2025-05-24 22:55:46 +02:00
Kyle Mendell
28c85990ba refactor: migrate shadcn-components to Svelte 5 and TW4 (#551)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-24 22:55:46 +02:00
Elias Schneider
05b443d984 chore: add .well-known to development reverse proxy 2025-05-24 22:55:46 +02:00
Kyle Mendell
8326bfd136 chore: add pocket-id to .gitignore 2025-05-24 22:55:46 +02:00
Kyle Mendell
b2e89934de chore: remove pocket-id binary 2025-05-24 22:55:46 +02:00
Kyle Mendell
c726c1621b fix: animation speed set to max of 300ms 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
b71c84c355 refactor: some clean-up in OIDC service and controller (#550) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
3896b7bb3b chore: address linter's complaint in 1.0 branch (#546) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
cb2a9f9f7d refactor: replace create-one-time-access-token script with in-app functionality (#540) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
35b227cd17 ci/cd: update release pipelines (#541) 2025-05-24 22:55:46 +02:00
Elias Schneider
f8a7467ec0 refactor!: serve the static frontend trough the backend (#520)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-05-24 22:55:46 +02:00
Elias Schneider
bf710aec56 fix: custom logo not correctly loaded if UI configuration is disabled 2025-05-22 19:07:34 +02:00
Elias Schneider
005702e5b6 chore(translations): update translations via Crowdin (#556) 2025-05-21 15:15:21 +02:00
Patryk Mikołajczyk
66d47bf933 chore(translations): add Polish translations (#554)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-21 15:13:07 +02:00
github-actions[bot]
d6104bbb35 chore: update AAGUIDs (#547)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-18 21:23:43 -05:00
Jake Howard
a0e22036c8 refactor: update options API for simplewebauthn (#543) 2025-05-18 13:22:04 +02:00
Alessandro (Ale) Segala
21bf49c061 refactor: flaky unit test in db_bootstrap_test (#532) 2025-05-14 10:54:03 -05:00
Alessandro (Ale) Segala
a408ef797b refactor: switch SQLite driver to pure-Go implementation (#530) 2025-05-14 09:29:04 +02:00
Kyle Mendell
f1154257c5 refactor!: remove old DB env variables, and jwk migrations logic (#529) 2025-05-13 23:05:54 +02:00
github-actions[bot]
0ca78bef8d chore: update AAGUIDs (#523)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-12 07:05:16 +02:00
549 changed files with 27043 additions and 12808 deletions

View File

@@ -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",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/go:1": {},
"ghcr.io/devcontainers-extra/features/caddy:1": {}
"ghcr.io/devcontainers/features/go:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"svelte.svelte-vscode"
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}
},
// Use 'postCreateCommand' to run commands after the container is created.
// Install npm dependencies for the frontend.
"postCreateCommand": "npm install --prefix frontend"
// Features to add to the dev container. More info: https://containers.dev/features.
// "features": {},
// Use 'forwardPorts' to make a list of ports inside the container available locally.
// "forwardPorts": [],
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}
"containerEnv": {
"HOST": "0.0.0.0"
},
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
}

View File

@@ -1,4 +1,4 @@
node_modules
**/node_modules
# Output
.output
@@ -6,6 +6,9 @@ node_modules
/frontend/.svelte-kit
/frontend/build
/backend/bin
/backend/frontend/dist
/tests/.auth
/tests/.report
# Env
@@ -15,4 +18,5 @@ node_modules
# Application specific
data
/scripts/development
/scripts/development
/backend/GeoLite2-City.mmdb

View File

@@ -1,5 +1,5 @@
# 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
MAXMIND_LICENSE_KEY=
PUID=1000

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @pocket-id/maintainers

View File

@@ -1,7 +1,7 @@
name: "🐛 Bug Report"
description: "Report something that is not working as expected"
title: "🐛 Bug Report: "
labels: [bug]
type: 'Bug'
body:
- type: markdown
attributes:
@@ -36,13 +36,29 @@ body:
value: |
### Additional Information
- type: textarea
id: extra-information
id: version
validations:
required: true
attributes:
label: "Version and Environment"
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
label: "Pocket ID Version"
description: "Please specify the version of Pocket ID."
placeholder: "e.g., v0.24.1"
- type: textarea
id: database
validations:
required: true
attributes:
label: "Database"
description: "Please specify the database in use: SQLite or Postgres (including version)."
placeholder: "e.g., SQLite or Postgres 17"
- type: textarea
id: environment
validations:
required: true
attributes:
label: "OS and Environment"
description: "Please include the OS, whether you're using containers (Docker, Podman, etc) along with any environment-specific configurations, such your reverse proxy, that might be relevant."
placeholder: "e.g., Docker on Ubuntu 24.04, served using Traefik"
- type: textarea
id: log-files
validations:

View File

@@ -1,7 +1,7 @@
name: 🚀 Feature
description: "Submit a proposal for a new feature"
title: "🚀 Feature: "
labels: [feature]
type: 'Feature'
body:
- type: textarea
id: feature-description

View File

@@ -1,7 +1,7 @@
name: "🌐 Language request"
description: "You want to contribute to a language that isn't on Crowdin yet?"
title: "🌐 Language Request: <language name in english>"
labels: [language-request]
type: 'Language Request'
body:
- type: input
id: language-name-native

View File

@@ -32,8 +32,9 @@ jobs:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
uses: golangci/golangci-lint-action@v8.0.0
with:
version: v2.0.2
version: v2.4.0
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -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

100
.github/workflows/build-next.yml vendored Normal file
View File

@@ -0,0 +1,100 @@
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@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Setup Go
uses: actions/setup-go@v5
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: 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: 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

View File

@@ -45,130 +45,39 @@ jobs:
path: /tmp/docker-image.tar
retention-days: 1
test-sqlite:
test:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres]
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "npm"
cache-dependency-path: frontend/package-lock.json
node-version: 22
cache: "pnpm"
cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- 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: 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: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Install Playwright Browsers
working-directory: ./frontend
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Create Docker network
run: docker network create pocket-id-network
- name: Setup and Configure LLDAP Server
run: |
chmod +x ./scripts/tests/setup-lldap.sh
./scripts/tests/setup-lldap.sh
- name: Run Docker Container with Sqlite DB and LDAP
run: |
docker run -d --name pocket-id-sqlite \
--network pocket-id-network \
-p 80:80 \
-e APP_ENV=test \
pocket-id:test
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
run: npx playwright test
- name: Upload Frontend Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-sqlite
path: frontend/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-sqlite
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
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: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-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:
@@ -176,15 +85,14 @@ jobs:
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: steps.postgres-cache.outputs.cache-hit != 'true'
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: steps.postgres-cache.outputs.cache-hit == 'true'
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
@@ -201,7 +109,6 @@ jobs:
- 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
uses: actions/download-artifact@v4
with:
@@ -211,66 +118,37 @@ jobs:
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci
- name: Install test dependencies
run: pnpm --filter pocket-id-tests install --frozen-lockfile
- name: Install Playwright Browsers
working-directory: ./frontend
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Create Docker network
run: docker network create pocket-id-network
- name: Start Postgres DB
run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container (sqlite) with LDAP
if: matrix.db == 'sqlite'
working-directory: ./tests/setup
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
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Setup and Configure LLDAP Server
- name: Run Docker Container (postgres) with LDAP
if: matrix.db == 'postgres'
working-directory: ./tests/setup
run: |
chmod +x ./scripts/tests/setup-lldap.sh
./scripts/tests/setup-lldap.sh
- name: Wait for Postgres to start
run: |
for i in {1..5}; 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 and LDAP
run: |
docker run -d --name pocket-id-postgres \
--network pocket-id-network \
-p 80:80 \
-e APP_ENV=test \
-e DB_PROVIDER=postgres \
-e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
pocket-id:test
docker logs -f pocket-id-postgres &> /tmp/backend.log &
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
working-directory: ./frontend
run: npx playwright test
working-directory: ./tests
run: pnpm exec playwright test
- name: Upload Frontend Test Report
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-postgres
path: frontend/tests/.report
name: playwright-report-${{ matrix.db }}
path: tests/.report
include-hidden-files: true
retention-days: 15
@@ -278,7 +156,7 @@ jobs:
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-postgres
name: backend-${{ matrix.db }}
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

128
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,128 @@
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
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- uses: actions/setup-go@v5
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: 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: 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@v4
- name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -4,21 +4,21 @@ 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"
- '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"
- 'frontend/src/**'
- '.github/svelte-check-matcher.json'
- 'frontend/package.json'
- 'frontend/package-lock.json'
- 'frontend/tsconfig.json'
- 'frontend/svelte.config.js'
workflow_dispatch:
jobs:
@@ -36,24 +36,28 @@ jobs:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "lts/*"
cache: "npm"
cache-dependency-path: frontend/package-lock.json
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
working-directory: frontend
run: npm ci
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build Pocket ID Frontend
working-directory: frontend
run: npm run build
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: npm run check
run: pnpm --filter pocket-id-frontend check

View File

@@ -29,7 +29,7 @@ jobs:
working-directory: backend
run: |
set -e -o pipefail
go test -v ./... | tee /tmp/TestResults.log
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
if: always()
with:

View File

@@ -25,6 +25,7 @@ jobs:
run: |
mkdir -p backend/resources
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
rm data.json
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7

12
.gitignore vendored
View File

@@ -9,6 +9,8 @@ node_modules
/frontend/.svelte-kit
/frontend/build
/backend/bin
pocket-id
/tests/test-results/*.json
# OS
.DS_Store
@@ -17,7 +19,7 @@ Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.development-example
!.env.test
# Vite
@@ -30,13 +32,15 @@ vite.config.ts.timestamp-*
*.dll
*.so
*.dylib
/backend/.bin
/pocket-id
# Application specific
data
/frontend/tests/.auth
/frontend/tests/.report
pocket-id-backend
/tests/.auth
/tests/.report
/backend/GeoLite2-City.mmdb
/backend/frontend/dist
# Misc
.DS_Store

View File

@@ -1 +1 @@
0.53.0
1.9.0

View File

@@ -1,5 +1,8 @@
{
"recommendations": [
"inlang.vs-code-extension"
"golang.go",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}
}

View File

@@ -1,3 +1,4 @@
{
"go.buildTags": "e2etest"
"go.buildTags": "e2etest",
"prettier.documentSelectors": ["**/*.svelte"],
}

37
.vscode/tasks.json vendored
View File

@@ -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
}
}
]
}

View File

@@ -1,3 +1,258 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.1...v) (2025-08-24)
### Features
* support automatic db migration rollbacks ([#874](https://github.com/pocket-id/pocket-id/issues/874)) ([c114a2e](https://github.com/pocket-id/pocket-id/commit/c114a2edaae4c007c75c34c02e8b0bb011845cae))
### Bug Fixes
* don't force uuid for client id in postgres ([2ffc6ba](https://github.com/pocket-id/pocket-id/commit/2ffc6ba42af4742a13b77543142b66b3e826ab88))
* ensure SQLite has a writable temporary directory ([#876](https://github.com/pocket-id/pocket-id/issues/876)) ([1f3550c](https://github.com/pocket-id/pocket-id/commit/1f3550c9bd3aafd3bd2272ef47f3ed8736037d81))
* sort order incorrect for apps when using postgres ([d0392d2](https://github.com/pocket-id/pocket-id/commit/d0392d25edcaa5f3c7da2aad70febf63b47763fa))
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.0...v) (2025-08-24)
### Bug Fixes
* migration clears allowed users groups ([5971bfb](https://github.com/pocket-id/pocket-id/commit/5971bfbfa66ecfebf2b1c08d34fcbd8c18cdc046))
* wrong column type for reauthentication tokens in Postgres ([#869](https://github.com/pocket-id/pocket-id/issues/869)) ([1283314](https://github.com/pocket-id/pocket-id/commit/1283314f776a0ba43be7d796e7e2243e31f860de))
## [](https://github.com/pocket-id/pocket-id/compare/v1.7.0...v) (2025-08-23)
### Features
* add option to OIDC client to require re-authentication ([#747](https://github.com/pocket-id/pocket-id/issues/747)) ([0cb039d](https://github.com/pocket-id/pocket-id/commit/0cb039d35d49206011064e622f3bfd3d8f88720f))
* allow custom client IDs ([#864](https://github.com/pocket-id/pocket-id/issues/864)) ([a5efb95](https://github.com/pocket-id/pocket-id/commit/a5efb9506582884c70b9b1fd737ebdd44b101b47))
* display all accessible oidc clients in the dashboard ([#832](https://github.com/pocket-id/pocket-id/issues/832)) ([3188e92](https://github.com/pocket-id/pocket-id/commit/3188e92257afcaf7a16dd418e4c40626d7e1d034))
* login code font change ([#851](https://github.com/pocket-id/pocket-id/issues/851)) ([d28bfac](https://github.com/pocket-id/pocket-id/commit/d28bfac81fc24ee79e4896538a616f0a89ab30a5))
* **signup:** add default user groups and claims for new users ([#812](https://github.com/pocket-id/pocket-id/issues/812)) ([182d809](https://github.com/pocket-id/pocket-id/commit/182d8090286f9953171c6c410283be679889aca7))
### Bug Fixes
* authorization can't be revoked ([0aab3f3](https://github.com/pocket-id/pocket-id/commit/0aab3f3c7ad8c1b14939de3ded60c9f201eab8fc))
* delete webauthn session after login to prevent replay attacks ([fe003b9](https://github.com/pocket-id/pocket-id/commit/fe003b927ce7772692439992860c804de89ce424))
* **deps:** bump rollup from 4.45.3 to 4.46.3 ([#845](https://github.com/pocket-id/pocket-id/issues/845)) ([b5e6371](https://github.com/pocket-id/pocket-id/commit/b5e6371eaaf3d9e85d8b05c457487c4425fa8381))
* enable foreign key check for sqlite ([#863](https://github.com/pocket-id/pocket-id/issues/863)) ([625f235](https://github.com/pocket-id/pocket-id/commit/625f23574001ebd7074b8d98d448a2811847be16))
* ferated identities can't be cleared ([24e2742](https://github.com/pocket-id/pocket-id/commit/24e274200fe4002d01c58cc3fa74094b598d7599))
* for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date ([#855](https://github.com/pocket-id/pocket-id/issues/855)) ([7ab0fd3](https://github.com/pocket-id/pocket-id/commit/7ab0fd30286e6b67b5ce586484d82a20c42b471d))
* ignore client secret if client is public ([#836](https://github.com/pocket-id/pocket-id/issues/836)) ([7b1f6b8](https://github.com/pocket-id/pocket-id/commit/7b1f6b88572bac1f3e838a9e904917fbd5fbdf61))
* move audit log call before TX is committed ([#854](https://github.com/pocket-id/pocket-id/issues/854)) ([9339e88](https://github.com/pocket-id/pocket-id/commit/9339e88a5a26ff77a5e40149cbb1a5b339b7ec6a))
* non admin users can't revoke oidc client but see edit link ([0e44f24](https://github.com/pocket-id/pocket-id/commit/0e44f245afcdf8179bf619613ca9ef4bffa176ca))
* oidc client advanced options color ([fc0c99a](https://github.com/pocket-id/pocket-id/commit/fc0c99a232b0efb1a5b5d2c551102418b1080293))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.4...v) (2025-08-10)
### Features
* add robots.txt to block indexing ([#806](https://github.com/pocket-id/pocket-id/issues/806)) ([06e1656](https://github.com/pocket-id/pocket-id/commit/06e1656923eb2f4531be497716f9147c09d60b65))
* add support for `code_challenge_methods_supported` ([#794](https://github.com/pocket-id/pocket-id/issues/794)) ([d479817](https://github.com/pocket-id/pocket-id/commit/d479817b6a7ca4807b5de500b3ba713d436b0770))
* Support OTel and JSON for logs (via log/slog) ([#760](https://github.com/pocket-id/pocket-id/issues/760)) ([78266e3](https://github.com/pocket-id/pocket-id/commit/78266e3e4cab2b23249c3baf20f4387d00eebd9e))
* support reading secret env vars from _FILE ([#799](https://github.com/pocket-id/pocket-id/issues/799)) ([0a3b1c6](https://github.com/pocket-id/pocket-id/commit/0a3b1c653050f2237d30ec437c5de88baa704a25))
* user application dashboard ([#727](https://github.com/pocket-id/pocket-id/issues/727)) ([484c2f6](https://github.com/pocket-id/pocket-id/commit/484c2f6ef20efc1fade1a41e2aeace54c7bb4f1b))
### Bug Fixes
* admins can not delete or disable their own account ([f0c144c](https://github.com/pocket-id/pocket-id/commit/f0c144c51c635bc348222a00d3bc88bc4e0711ef))
* authorization animation not working ([9ac5d51](https://github.com/pocket-id/pocket-id/commit/9ac5d5118710cad59c8c4ce7cef7ab09be3de664))
* custom claims input suggestions instantly close after opening ([4d59e72](https://github.com/pocket-id/pocket-id/commit/4d59e7286666480e20c728787a95e82513509240))
* delete WebAuthn registration session after use ([#783](https://github.com/pocket-id/pocket-id/issues/783)) ([c8478d7](https://github.com/pocket-id/pocket-id/commit/c8478d75bed7295625cd3cf62ef46fcd95902410))
* set input type 'email' for email-based login ([#776](https://github.com/pocket-id/pocket-id/issues/776)) ([d541c9a](https://github.com/pocket-id/pocket-id/commit/d541c9ab4af8d7283891a80f886dd5d4ebc52f53))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.3...v) (2025-07-21)
### Bug Fixes
* migration fails on postgres ([#762](https://github.com/pocket-id/pocket-id/issues/762)) ([35d5f88](https://github.com/pocket-id/pocket-id/commit/35d5f887ce7c88933d7e4c2f0acd2aeedd18c214))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.2...v) (2025-07-21)
### Bug Fixes
* allow passkey names up to 50 characters ([b03e91b](https://github.com/pocket-id/pocket-id/commit/b03e91b6530c2393ad20ac49aa2cb2b4962651b2))
* ensure user inputs are normalized ([#724](https://github.com/pocket-id/pocket-id/issues/724)) ([7b4ccd1](https://github.com/pocket-id/pocket-id/commit/7b4ccd1f306f4882c52fe30133fcda114ef0d18b))
* show rename and delete buttons for passkeys without hovering over the row ([2952b15](https://github.com/pocket-id/pocket-id/commit/2952b1575542ecd0062fe740e2d6a3caad05190d))
* use object-contain for images on oidc-client list ([d3bc179](https://github.com/pocket-id/pocket-id/commit/d3bc1797b65ec8bc9201c55d06f3612093f3a873))
* use user-agent for identifying known device signins ([ef1d599](https://github.com/pocket-id/pocket-id/commit/ef1d5996624fc534190f80a26f2c48bbad206f49))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.1...v) (2025-07-09)
### Bug Fixes
* ensure confirmation dialog shows on top of other components ([f103a54](https://github.com/pocket-id/pocket-id/commit/f103a547904070c5b192e519c8b5a8fed9d80e96))
* login failures on Postgres when IP is null ([#737](https://github.com/pocket-id/pocket-id/issues/737)) ([e1de593](https://github.com/pocket-id/pocket-id/commit/e1de593dcd30b7b04da3b003455134992b702595))
## [](https://github.com/pocket-id/pocket-id/compare/v1.5.0...v) (2025-07-06)
### Features
* add "key-rotate" command ([#709](https://github.com/pocket-id/pocket-id/issues/709)) ([8c8fc23](https://github.com/pocket-id/pocket-id/commit/8c8fc2304d8f33c1fea54b1138b109f282e78b8b))
* add support for OAuth 2.0 Authorization Server Issuer Identification ([bf04256](https://github.com/pocket-id/pocket-id/commit/bf042563e997d57bb087705a5789fd72ffbed467))
* distroless container additional variant + healthcheck command ([#716](https://github.com/pocket-id/pocket-id/issues/716)) ([1a41b05](https://github.com/pocket-id/pocket-id/commit/1a41b05f60d487fff78703bec1d4e832f96fd071))
* encrypt private keys saved on disk and in database ([#682](https://github.com/pocket-id/pocket-id/issues/682)) ([5550729](https://github.com/pocket-id/pocket-id/commit/5550729120ac9f5e9361c7f9cf25b9075a33a94a))
* enhance language selection message and add translation contribution link ([be52660](https://github.com/pocket-id/pocket-id/commit/be526602273c1689cb4057ca96d4214e7f817d1d))
### Bug Fixes
* actually fix linter issues ([#720](https://github.com/pocket-id/pocket-id/issues/720)) ([7fe83f8](https://github.com/pocket-id/pocket-id/commit/7fe83f8087f033f957bb6e0eee5e0c159417e1cd))
* add missing error check in initial user setup ([fceb6fa](https://github.com/pocket-id/pocket-id/commit/fceb6fa7b4701a3645c4c2353bcd108b15d69ded))
* allow profile picture update even if "allow own account edit" enabled ([9872608](https://github.com/pocket-id/pocket-id/commit/9872608d61a486f7b775f314d9392e0620bcd891))
* app config forms not updating with latest values ([#696](https://github.com/pocket-id/pocket-id/issues/696)) ([92c57ad](https://github.com/pocket-id/pocket-id/commit/92c57ada1a11f76963e36ca0a81bca8f52dbc84e))
* auth fails when client IP is empty on Postgres ([#695](https://github.com/pocket-id/pocket-id/issues/695)) ([031181a](https://github.com/pocket-id/pocket-id/commit/031181ad2ae8fae94cc5793dd1c614e79476a766))
* custom claims input suggestions flickering ([49f1ab2](https://github.com/pocket-id/pocket-id/commit/49f1ab2f75df97d551fff5acbadcd55df74af617))
* keep sidebar in settings sticky ([e46f60a](https://github.com/pocket-id/pocket-id/commit/e46f60ac8d6944bcea54d0708af1950d98f66c3c))
* linter issues ([#719](https://github.com/pocket-id/pocket-id/issues/719)) ([43f0114](https://github.com/pocket-id/pocket-id/commit/43f0114c579f7b5b32b372e09f46bcb2a9d7796e))
* show friendly name in user group selection ([5c9e504](https://github.com/pocket-id/pocket-id/commit/5c9e504291b3bffe947bcbe907701806e301d1fe))
* support non UTF-8 LDAP IDs ([#714](https://github.com/pocket-id/pocket-id/issues/714)) ([8131579](https://github.com/pocket-id/pocket-id/commit/81315790a8aa601a2565a1b54807df1e68f06dc5))
* token introspection authentication not handled correctly ([#704](https://github.com/pocket-id/pocket-id/issues/704)) ([aefb308](https://github.com/pocket-id/pocket-id/commit/aefb30853677baf7ed29ac8b539e1aadf56e14a4))
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27)
### Features
* improve initial admin creation workflow ([287314f](https://github.com/pocket-id/pocket-id/commit/287314f01644e42ddb2ce1b1115bd14f2f0c1768))
* redact sensitive app config variables if set with env variable ([ba61cdb](https://github.com/pocket-id/pocket-id/commit/ba61cdba4eb3d5659f3ae6b6c21249985c0aa630))
* self-service user signup ([#672](https://github.com/pocket-id/pocket-id/issues/672)) ([dcd1ae9](https://github.com/pocket-id/pocket-id/commit/dcd1ae96e048115be34b0cce275054e990462ebf))
### Bug Fixes
* double double full stops for certain error messages ([d070b9a](https://github.com/pocket-id/pocket-id/commit/d070b9a778d7d1a51f2fa62d003f2331a96d6c91))
* error page flickering after sign out ([1a77bd9](https://github.com/pocket-id/pocket-id/commit/1a77bd9914ea01e445ff3d6e116c9ed3bcfbf153))
* improve accent color picker disabled state ([d976bf5](https://github.com/pocket-id/pocket-id/commit/d976bf5965eda10e3ecb71821c23e93e5d712a02))
* less noisy logging for certain GET requests ([#681](https://github.com/pocket-id/pocket-id/issues/681)) ([043f82a](https://github.com/pocket-id/pocket-id/commit/043f82ad794eb64a5550d8b80703114a055701d9))
* margin of user sign up description ([052ac00](https://github.com/pocket-id/pocket-id/commit/052ac008c3a8c910d1ce79ee99b2b2f75e4090f4))
* remove duplicate request logging ([#678](https://github.com/pocket-id/pocket-id/issues/678)) ([988c425](https://github.com/pocket-id/pocket-id/commit/988c425150556b32cff1d341a21fcc9c69d9aaf8))
* users can't be updated by admin if self account editing is disabled ([29cb551](https://github.com/pocket-id/pocket-id/commit/29cb5513a03d1a9571969c8a42deec9b2bdee037))
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.0...v) (2025-06-22)
### Bug Fixes
* app not starting if UI config is disabled and Postgres is used ([7d36bda](https://github.com/pocket-id/pocket-id/commit/7d36bda769e25497dec6b76206a4f7e151b0bd72))
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.1...v) (2025-06-19)
### Features
* allow setting unix socket mode ([#661](https://github.com/pocket-id/pocket-id/issues/661)) ([7677a3d](https://github.com/pocket-id/pocket-id/commit/7677a3de2c923c11a58bc8c4d1b2121d403a1504))
* auto-focus on the login buttons ([#647](https://github.com/pocket-id/pocket-id/issues/647)) ([d679530](https://github.com/pocket-id/pocket-id/commit/d6795300b158b85dd9feadd561b6ecd891f5db0d))
* configurable local ipv6 ranges for audit log ([#657](https://github.com/pocket-id/pocket-id/issues/657)) ([d548523](https://github.com/pocket-id/pocket-id/commit/d5485238b8fd4cc566af00eae2b17d69a119f991))
* location filter for global audit log ([#662](https://github.com/pocket-id/pocket-id/issues/662)) ([ac5a121](https://github.com/pocket-id/pocket-id/commit/ac5a121f664b8127d0faf30c0f93432f30e7f33a))
* ui accent colors ([#643](https://github.com/pocket-id/pocket-id/issues/643)) ([883877a](https://github.com/pocket-id/pocket-id/commit/883877adec6fc3e65bd5a705499449959b894fb5))
* use icon instead of text on application image update hover state ([215531d](https://github.com/pocket-id/pocket-id/commit/215531d65c6683609b0b4a5505fdb72696fdb93e))
### Bug Fixes
* allow images with uppercase file extension ([1bcb50e](https://github.com/pocket-id/pocket-id/commit/1bcb50edc335886dd722a4c69960c48cc3cd1687))
* center oidc client images if they are smaller than the box ([946c534](https://github.com/pocket-id/pocket-id/commit/946c534b0877a074a6b658060f9af27e4061397c))
* explicitly cache images to prevent unexpected behavior ([2e5d268](https://github.com/pocket-id/pocket-id/commit/2e5d2687982186c12e530492292d49895cb6043a))
* reduce duration of animations on login and signin page ([#648](https://github.com/pocket-id/pocket-id/issues/648)) ([d770448](https://github.com/pocket-id/pocket-id/commit/d77044882d5a41da22df1c0099c1eb1f20bcbc5b))
* use inline style for dynamic background image URL instead of Tailwind class ([bef77ac](https://github.com/pocket-id/pocket-id/commit/bef77ac8dca2b98b6732677aaafbc28f79d00487))
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.0...v) (2025-06-09)
### Bug Fixes
* change timestamp of `client_credentials.sql` migration ([2935236](https://github.com/pocket-id/pocket-id/commit/2935236acee9c78c2fe6787ec8b5f53ae0eca047))
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
### Features
* add API endpoint for user authorized clients ([d217083](https://github.com/pocket-id/pocket-id/commit/d217083059120171d5c555b09eefe6ba3c8a8d42))
* add unix socket support ([#615](https://github.com/pocket-id/pocket-id/issues/615)) ([035b2c0](https://github.com/pocket-id/pocket-id/commit/035b2c022bfd2b98f13355ec7a126e0f1ab3ebd8))
* allow introspection and device code endpoints to use Federated Client Credentials ([#640](https://github.com/pocket-id/pocket-id/issues/640)) ([b62b61f](https://github.com/pocket-id/pocket-id/commit/b62b61fb017dba31a6fc612c138bebf370d3956c))
* JWT bearer assertions for client authentication ([#566](https://github.com/pocket-id/pocket-id/issues/566)) ([05bfe00](https://github.com/pocket-id/pocket-id/commit/05bfe0092450c9bc26d03c6a54c21050eef8f63a))
* new color theme for the UI ([97f7326](https://github.com/pocket-id/pocket-id/commit/97f7326da40265a954340d519661969530f097a0))
* oidc client data preview ([#624](https://github.com/pocket-id/pocket-id/issues/624)) ([c111b79](https://github.com/pocket-id/pocket-id/commit/c111b7914731a3cafeaa55102b515f84a1ad74dc))
### Bug Fixes
* don't load app config and user on every route change ([bdcef60](https://github.com/pocket-id/pocket-id/commit/bdcef60cab6a61e1717661e918c42e3650d23fee))
* misleading text for disable animations option ([657a51f](https://github.com/pocket-id/pocket-id/commit/657a51f7ed8a77e8a937971032091058aacfded6))
* OIDC client image can't be deleted ([61b62d4](https://github.com/pocket-id/pocket-id/commit/61b62d461200c1359a16c92c9c62530362a4785c))
* UI config overridden by env variables don't apply on first start ([5e9096e](https://github.com/pocket-id/pocket-id/commit/5e9096e328741ba2a0e03835927fe62e6aea2a89))
* use full width for audit log filters ([575b2f7](https://github.com/pocket-id/pocket-id/commit/575b2f71e9f1ff9c4f6fd411b136676c213b7201))
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)
### Features
* auto detect callback url ([#583](https://github.com/pocket-id/pocket-id/issues/583)) ([20d3f78](https://github.com/pocket-id/pocket-id/commit/20d3f780a2a431d0a48cece0f0764b6e4d53c1b9))
### Bug Fixes
* allow users to update their locale even when own account update disabled ([6c00aaa](https://github.com/pocket-id/pocket-id/commit/6c00aaa3efa75c76d340718698a0f4556e8de268))
* clear default app config variables from database ([decf8ec](https://github.com/pocket-id/pocket-id/commit/decf8ec70b5f6a69fe201d6e4ad60ee62e374ad0))
* don't use TOFU for logout callback URLs ([#588](https://github.com/pocket-id/pocket-id/issues/588)) ([256f74d](https://github.com/pocket-id/pocket-id/commit/256f74d0a348a835107fd5b17b9d57b1e845029e))
* fallback to primary language if no translation available for specific country ([2440379](https://github.com/pocket-id/pocket-id/commit/2440379cd11b4a6da7c52b122ba8f49d7c72ce1d))
* improve spacing on auth screens ([04fcf11](https://github.com/pocket-id/pocket-id/commit/04fcf1110e97b42dc5f0c20e169c569075d1e797))
* page scrolls up on form submisssion ([31ad904](https://github.com/pocket-id/pocket-id/commit/31ad904367e53dd47a15abcce5402dfe84828a14))
* run jobs at interval instead of specific time ([#585](https://github.com/pocket-id/pocket-id/issues/585)) ([6d6dc66](https://github.com/pocket-id/pocket-id/commit/6d6dc6646a39921a604b6c825d3e7e76af6c693b))
* show LAN for auditlog location for internal networks ([b874681](https://github.com/pocket-id/pocket-id/commit/b8746818240fde052e6f3b5db5c3355d7bbfcbda))
* small fixes in analytics_job ([#582](https://github.com/pocket-id/pocket-id/issues/582)) ([3d402fc](https://github.com/pocket-id/pocket-id/commit/3d402fc0ca30626c95b8f7accc274b9f2ab228b9))
* whitelist authorization header for CORS ([b9489b5](https://github.com/pocket-id/pocket-id/commit/b9489b5e9a32a2a3f54d48705e731a7bcf188d20))
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
### Features
* add daily heartbeat request for counting Pocket ID instances ([#578](https://github.com/pocket-id/pocket-id/issues/578)) ([e0ec607](https://github.com/pocket-id/pocket-id/commit/e0ec60719883c0230f1a16611b943d6f6f637157))
* require user verification for passkey sign in ([68e4b67](https://github.com/pocket-id/pocket-id/commit/68e4b67bd212e31ecc20277bfd293c94bf7f3642))
* show allowed group count on oidc client list ([#567](https://github.com/pocket-id/pocket-id/issues/567)) ([38d7ee4](https://github.com/pocket-id/pocket-id/commit/38d7ee4432e0dacc2cfbabad4bfd9336b8b84079))
### Bug Fixes
* run user group count inside a transaction ([f03b80f](https://github.com/pocket-id/pocket-id/commit/f03b80f9d7f2529d8cef23ca6a742a914a4ec883))
* use ldapAttributeUserUsername for finding group members ([#565](https://github.com/pocket-id/pocket-id/issues/565)) ([f66e8e8](https://github.com/pocket-id/pocket-id/commit/f66e8e8b4478c66ed1ada9168a272b33dbf494d0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.53.0...v) (2025-05-24)
### ⚠ BREAKING CHANGES
* serve the static frontend trough the backend (#520)
* remove old DB env variables, and jwk migrations logic (#529)
### Features
* improve buttons styling ([c37386f](https://github.com/pocket-id/pocket-id/commit/c37386f8b2f2c64bd9e7c437879a2217846852b5))
### Bug Fixes
* add back month and year selection for date picker ([6c35570](https://github.com/pocket-id/pocket-id/commit/6c35570e78813ca6af1bae6a0374d7483bff9824))
* animation speed set to max of 300ms ([c726c16](https://github.com/pocket-id/pocket-id/commit/c726c1621b8bd88b20cb05263f6d10888f0af8e2))
* authorize page doesn't load ([c3a03db](https://github.com/pocket-id/pocket-id/commit/c3a03db8b0f87cddc927481cfad2ccc391f98869))
* custom logo not correctly loaded if UI configuration is disabled ([bf710ae](https://github.com/pocket-id/pocket-id/commit/bf710aec5625c9dcb43c83d920318a036a135bae))
* ldap tests ([4dc0b2f](https://github.com/pocket-id/pocket-id/commit/4dc0b2f37f9a57ba1c7ea084dc2a713f283d1b14))
* remove curly bracket from user group URL ([5fa15f6](https://github.com/pocket-id/pocket-id/commit/5fa15f60984a8f2a02f15900860c3a3097032e1b))
* remove nested button in user group list ([f57c8d3](https://github.com/pocket-id/pocket-id/commit/f57c8d347e127027378aad8831a8e4dfebfef060))
* show correct app name on sign out page ([131f470](https://github.com/pocket-id/pocket-id/commit/131f470757044fddd0989a76e9dc9e310f19819c))
* trim whitespaces from string inputs ([059073d](https://github.com/pocket-id/pocket-id/commit/059073d4c24e34c142dddd4c150c384779fb51a9))
* use pointer cursor for menu items ([f820fc8](https://github.com/pocket-id/pocket-id/commit/f820fc830161499edb0da2df334e4e473d5825ae))
* use same color as title for description in alert ([e19b33f](https://github.com/pocket-id/pocket-id/commit/e19b33fc2e2b9dd149da1f9351aca2e839ffae04))
### Code Refactoring
* remove old DB env variables, and jwk migrations logic ([#529](https://github.com/pocket-id/pocket-id/issues/529)) ([f115425](https://github.com/pocket-id/pocket-id/commit/f1154257c5a9ac5c95d81343a31c02251631b203))
* serve the static frontend trough the backend ([#520](https://github.com/pocket-id/pocket-id/issues/520)) ([f8a7467](https://github.com/pocket-id/pocket-id/commit/f8a7467ec0e939f90d19211a0a0efc5e17a58127))
## [](https://github.com/pocket-id/pocket-id/compare/v0.52.0...v) (2025-05-08)

View File

@@ -17,7 +17,7 @@ Before you submit the pull request for review please ensure that
example:
```
feat(share): add password protection
fix: hide global audit log switch for non admin users
```
Where `TYPE` can be:
@@ -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
- Your pull request has a detailed description
- You run `npm run format` to format the code
- You run `pnpm format` to format the code
## Setup project
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
## Development Environment
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
There are two ways to get the development environment setup:
### 1. Install required tools
#### With Dev Containers
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
## 1. Using DevContainers
1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed
2. Clone and open the repo in VS Code
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
## 2. Manual
#### Without Dev Containers
### Backend
If you don't use Dev Containers, you need to install the following tools manually:
The backend is built with [Gin](https://gin-gonic.com) and written in Go.
- [Node.js](https://nodejs.org/en/download/) >= 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
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
3. Start the backend with `go run -tags e2etest ./cmd`
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Start the backend with `go run -tags exclude_frontend ./cmd`
### Frontend
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript.
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
#### Setup
1. Open the `pocket-id` project folder
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
3. Install the dependencies with `pnpm install`
4. Start the frontend with `pnpm dev`
1. Open the `frontend` folder
2. Copy the `.env.example` file to `.env`
3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev`
### Reverse Proxy
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
#### Setup
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
You're all set!
## Debugging
1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json))
2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json))
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
### Testing
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:
1. Start the backend normally
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js`
3. Run the tests with `npm run test`
1. Install the dependencies from the root of the project `pnpm install`
2. Visit the setup folder by running `cd tests/setup`
3. Start the test environment by running `docker compose up -d --build`
4. Go back to the test folder by running `cd ..`
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.

View File

@@ -1,55 +1,60 @@
# This file uses multi-stage builds to build the application from source, including the front-end
# Tags passed to "go build"
ARG BUILD_TAGS=""
ARG VERSION="unknown"
# 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
RUN corepack enable
WORKDIR /build
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY frontend/package.json ./frontend/
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
COPY ./frontend ./frontend/
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
# Stage 2: Build Backend
FROM golang:1.24-alpine AS backend-builder
FROM golang:1.25-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /app/backend
WORKDIR /build
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 \
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
COPY .version .version
WORKDIR /build/cmd
RUN VERSION=$(cat /build/.version) \
CGO_ENABLED=0 \
GOOS=linux \
go build \
-tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION}" \
-o /app/backend/pocket-id-backend \
.
-tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
-trimpath \
-o /build/pocket-id \
.
# 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/
FROM alpine
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
RUN apk add --no-cache curl su-exec
COPY ./scripts ./scripts
RUN find ./scripts -name "*.sh" -exec chmod +x {} \;
COPY --from=backend-builder /build/pocket-id /app/pocket-id
COPY ./scripts/docker /app/docker
EXPOSE 80
RUN chmod +x /app/pocket-id && \
find /app/docker -name "*.sh" -exec chmod +x {} \;
EXPOSE 1411
ENV APP_ENV=production
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
CMD ["sh", "./scripts/docker/entrypoint.sh"]
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"]

18
Dockerfile-distroless Normal file
View File

@@ -0,0 +1,18 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM gcr.io/distroless/static-debian12:nonroot
# TARGETARCH can be "amd64" or "arm64"
ARG TARGETARCH
WORKDIR /app
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
EXPOSE 1411
ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
CMD ["/app/pocket-id"]

22
Dockerfile-prebuilt Normal file
View File

@@ -0,0 +1,22 @@
# This Dockerfile embeds a pre-built binary for the given Linux architecture
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
FROM alpine
# TARGETARCH can be "amd64" or "arm64"
ARG TARGETARCH
WORKDIR /app
RUN apk add --no-cache curl su-exec
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
COPY ./scripts/docker /app/docker
EXPOSE 1411
ENV APP_ENV=production
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
ENTRYPOINT ["/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"]

12
backend/.air.toml Normal file
View 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

View 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

View File

@@ -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
View File

@@ -15,3 +15,4 @@
# vendor/
./data
.env
pocket-id

View File

@@ -1,11 +1,9 @@
package main
import (
"log"
_ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
)
// @title Pocket ID API
@@ -13,8 +11,5 @@ import (
// @description.markdown
func main() {
err := bootstrap.Bootstrap()
if err != nil {
log.Fatal(err.Error())
}
cmds.Execute()
}

View 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
}

View File

@@ -0,0 +1,77 @@
//go:build !exclude_frontend
package frontend
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
)
//go:embed all:dist/*
var frontendFS embed.FS
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) {
// Try to serve the requested file
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
// File doesn't exist, serve index.html instead
c.Request.URL.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)
}

View File

@@ -0,0 +1,5 @@
package frontend
import "errors"
var ErrFrontendNotIncluded = errors.New("frontend is not included")

View File

@@ -1,99 +1,120 @@
module github.com/pocket-id/pocket-id/backend
go 1.24.0
go 1.25
require (
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/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0
github.com/gin-gonic/gin v1.10.0
github.com/go-co-op/gocron/v2 v2.15.0
github.com/fxamacker/cbor/v2 v2.9.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-playground/validator/v10 v10.25.0
github.com/go-playground/validator/v10 v10.27.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/golang-migrate/migrate/v4 v4.18.3
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/lestrrat-go/jwx/v3 v3.0.0-beta1
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/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
github.com/prometheus/client_golang v1.22.0
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
github.com/samber/slog-gin v1.15.1
github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
go.opentelemetry.io/otel v1.35.0
go.opentelemetry.io/otel/metric v1.35.0
go.opentelemetry.io/otel v1.37.0
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.35.0
golang.org/x/crypto v0.36.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
gorm.io/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
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/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 (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.12.10 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/bytedance/sonic v1.14.0 // 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.5 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // 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-logr/logr v1.4.2 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect
github.com/go-webauthn/x v0.1.23 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // 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/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/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/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/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/v2 v2.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // 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/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/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/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
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
@@ -108,19 +129,21 @@ require (
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/otel/log v0.10.0 // indirect
go.opentelemetry.io/otel/sdk/log v0.10.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.14.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.12.0 // indirect
golang.org/x/sys v0.31.0 // indirect
golang.org/x/text v0.23.0 // indirect
golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // 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.5 // indirect
google.golang.org/protobuf v1.36.7 // 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
)

View File

@@ -8,27 +8,28 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/sonic v1.12.10 h1:uVCQr6oS5669E9ZVW0HyksTLfNS7Q/9hV6IVS4nEMsI=
github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
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/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/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.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
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/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
@@ -43,29 +44,35 @@ 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-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
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/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
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/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
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/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -74,28 +81,38 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
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/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
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/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-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/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
@@ -110,12 +127,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.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
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/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -130,6 +149,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/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/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -142,10 +163,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -154,18 +173,22 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
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/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/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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
@@ -187,14 +210,18 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime 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/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
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/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -207,16 +234,24 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
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/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -224,18 +259,21 @@ 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.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
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/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
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=
@@ -244,8 +282,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.
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/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
@@ -268,45 +306,49 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX
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.10.0 h1:1CXmspaRITvFcjA4kyVszuG4HjA61fPDxMb7q3BuyF0=
go.opentelemetry.io/otel/log v0.10.0/go.mod h1:PbVdm9bXKku/gL0oFfUF4wwsQsOPlpo4VEqjvxih+FM=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
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.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
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/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
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-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
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.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
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.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.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-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -318,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.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.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
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-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -327,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.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
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-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -341,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.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
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/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=
@@ -353,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.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.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.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -361,25 +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.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
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-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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
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.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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -387,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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
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=

View File

@@ -1,7 +1,7 @@
package bootstrap
import (
"log"
"fmt"
"os"
"path"
"strings"
@@ -12,17 +12,17 @@ import (
)
// initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() {
func initApplicationImages() error {
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)
return fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
return fmt.Errorf("failed to read directory: %w", err)
}
// Copy images from the images directory to the application-images directory if they don't already exist
@@ -35,9 +35,11 @@ func initApplicationImages() {
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
return fmt.Errorf("failed to copy file: %w", err)
}
}
return nil
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {

View File

@@ -3,7 +3,7 @@ package bootstrap
import (
"context"
"fmt"
"log"
"log/slog"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
@@ -11,27 +11,26 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
func Bootstrap() error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
initApplicationImages()
// Perform migrations for changes
migrateConfigDBConnstring()
migrateKey()
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
func Bootstrap(ctx context.Context) error {
// 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")
err = initApplicationImages()
if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Connect to the database
db := newDatabase()
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient)
@@ -44,7 +43,7 @@ func Bootstrap() error {
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, scheduler)
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
@@ -52,7 +51,7 @@ func Bootstrap() error {
// Init the router
router := initRouter(db, svc)
// Run all background serivces
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).
@@ -63,13 +62,14 @@ func Bootstrap() error {
// 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)
Run(shutdownCtx) //nolint:contextcheck
if err != nil {
log.Printf("Error shutting down services: %v", err)
slog.Error("Error shutting down services", slog.Any("error", err))
}
return nil

View File

@@ -1,34 +0,0 @@
package bootstrap
import (
"log"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// Performs the migration of the database connection string
// See: https://github.com/pocket-id/pocket-id/pull/388
func migrateConfigDBConnstring() {
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
// Check if we're using the deprecated SqliteDBPath env var
if common.EnvConfig.SqliteDBPath != "" {
connString := "file:" + common.EnvConfig.SqliteDBPath + "?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate"
common.EnvConfig.DbConnectionString = connString
common.EnvConfig.SqliteDBPath = ""
log.Printf("[WARN] Env var 'SQLITE_DB_PATH' is deprecated - use 'DB_CONNECTION_STRING' instead with the value: '%s'", connString)
}
case common.DbProviderPostgres:
// Check if we're using the deprecated PostgresConnectionString alias
if common.EnvConfig.PostgresConnectionString != "" {
common.EnvConfig.DbConnectionString = common.EnvConfig.PostgresConnectionString
common.EnvConfig.PostgresConnectionString = ""
log.Print("[WARN] Env var 'POSTGRES_CONNECTION_STRING' is deprecated - use 'DB_CONNECTION_STRING' instead with the same value")
}
default:
// We don't do anything here in the default case
// This is an error, but will be handled later on
}
}

View File

@@ -3,32 +3,39 @@ package bootstrap
import (
"errors"
"fmt"
"log"
"log/slog"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/glebarez/sqlite"
"github.com/golang-migrate/migrate/v4"
"github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/github"
"github.com/golang-migrate/migrate/v4/source/iofs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
gormLogger "gorm.io/gorm/logger"
"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) {
db, err := connectDatabase()
func NewDatabase() (db *gorm.DB, err error) {
db, err = connectDatabase()
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()
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
@@ -40,23 +47,24 @@ func newDatabase() (db *gorm.DB) {
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
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
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 {
// Use the embedded migrations
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
// Embedded migrations via iofs
path := "migrations/" + string(common.EnvConfig.DbProvider)
source, err := iofs.New(resources.FS, path)
if err != nil {
return fmt.Errorf("failed to create embedded migration source: %w", err)
}
@@ -66,14 +74,66 @@ func migrateDatabase(driver database.Driver) error {
return fmt.Errorf("failed to create migration instance: %w", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %w", err)
requiredVersion, err := getRequiredMigrationVersion(path)
if err != nil {
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
}
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) {
var dialector gorm.Dialector
@@ -83,10 +143,21 @@ func connectDatabase() (db *gorm.DB, err error) {
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
sqliteutil.RegisterSqliteFunctions()
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil {
return nil, err
}
dialector = sqlite.Open(common.EnvConfig.DbConnectionString)
// 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
}
dialector = sqlite.Open(connString)
case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
@@ -99,37 +170,268 @@ func connectDatabase() (db *gorm.DB, err error) {
for i := 1; i <= 3; i++ {
db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true,
Logger: getLogger(),
Logger: getGormLogger(),
})
if err == nil {
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
return db, nil
}
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
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 getLogger() logger.Interface {
isProduction := common.EnvConfig.AppEnv == "production"
var logLevel logger.LogLevel
if isProduction {
logLevel = logger.Error
} else {
logLevel = logger.Info
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
return logger.New(
log.New(os.Stdout, "\r\n", log.LstdFlags),
logger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: logLevel,
IgnoreRecordNotFoundError: isProduction,
ParameterizedQueries: isProduction,
Colorful: !isProduction,
},
)
// 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 "", "", 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 "", "", 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 "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
}
return parsedConnString, dbPath, 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
}
}
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
}
// Adds the default (and some required) parameters to the SQLite connection string.
// 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
// Get the "query string" from the connection string if present
qs := connStringUrl.Query()
if len(qs) == 0 {
qs = make(url.Values, 2)
}
// If the database is in-memory, we must ensure that cache=shared is set
if isMemoryDB {
qs["cache"] = []string{"shared"}
}
// Check if the database is read-only or immutable
isReadOnly := false
if len(qs["mode"]) > 0 {
// Keep the first value only
qs["mode"] = []string{
strings.ToLower(qs["mode"][0]),
}
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.AppEnv == "production" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
} else {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(),
)
}
return slogGorm.New(loggerOpts...)
}

View File

@@ -0,0 +1,330 @@
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&cache=shared",
},
{
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: "in-memory database with cache already set",
input: "file::memory:?cache=private",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
},
{
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)
}
}

View File

@@ -3,6 +3,9 @@
package bootstrap
import (
"log/slog"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -14,7 +17,13 @@ import (
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
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)
},
}

View File

@@ -1,136 +0,0 @@
package bootstrap
import (
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
privateKeyFilePem = "jwt_private_key.pem"
)
func migrateKey() {
err := migrateKeyInternal(common.EnvConfig.KeysPath)
if err != nil {
log.Fatalf("failed to perform migration of keys: %v", err)
}
}
func migrateKeyInternal(basePath string) error {
// First, check if there's already a JWK stored
jwkPath := filepath.Join(basePath, service.PrivateKeyFile)
ok, err := utils.FileExists(jwkPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
}
if ok {
// There's already a key as JWK, so we don't do anything else here
return nil
}
// Check if there's a PEM file
pemPath := filepath.Join(basePath, privateKeyFilePem)
ok, err = utils.FileExists(pemPath)
if err != nil {
return fmt.Errorf("failed to check if private key file (PEM) exists at path '%s': %w", pemPath, err)
}
if !ok {
// No file to migrate, return
return nil
}
// Load and validate the key
key, err := loadKeyPEM(pemPath)
if err != nil {
return fmt.Errorf("failed to load private key file (PEM) at path '%s': %w", pemPath, err)
}
err = service.ValidateKey(key)
if err != nil {
return fmt.Errorf("key object is invalid: %w", err)
}
// Save the key as JWK
err = service.SaveKeyJWK(key, jwkPath)
if err != nil {
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
}
// Finally, delete the PEM file
err = os.Remove(pemPath)
if err != nil {
return fmt.Errorf("failed to remove migrated key at path '%s': %w", pemPath, err)
}
return nil
}
func loadKeyPEM(path string) (jwk.Key, error) {
// Load the key from disk and parse it
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read key data: %w", err)
}
key, err := jwk.ParseKey(data, jwk.WithPEM(true))
if err != nil {
return nil, fmt.Errorf("failed to parse key: %w", err)
}
// Populate the key ID using the "legacy" algorithm
keyId, err := generateKeyID(key)
if err != nil {
return nil, fmt.Errorf("failed to generate key ID: %w", err)
}
err = key.Set(jwk.KeyIDKey, keyId)
if err != nil {
return nil, fmt.Errorf("failed to set key ID: %w", err)
}
// Populate other required fields
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)
service.EnsureAlgInKey(key)
return key, nil
}
// generateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key's PKIX-serialized structure.
// This is used for legacy keys, imported from PEM.
func generateKeyID(key jwk.Key) (string, error) {
// Export the public key and serialize it to PKIX (not in a PEM block)
// This is for backwards-compatibility with the algorithm used before the switch to JWK
pubKey, err := key.PublicKey()
if err != nil {
return "", fmt.Errorf("failed to get public key: %w", err)
}
var pubKeyRaw any
err = jwk.Export(pubKey, &pubKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to export public key: %w", err)
}
pubASN1, err := x509.MarshalPKIXPublicKey(pubKeyRaw)
if err != nil {
return "", fmt.Errorf("failed to marshal public key: %w", err)
}
// Compute SHA-256 hash of the public key
hash := sha256.New()
hash.Write(pubASN1)
hashed := hash.Sum(nil)
// Truncate the hash to the first 8 bytes for a shorter Key ID
shortHash := hashed[:8]
// Return Base64 encoded truncated hash as Key ID
return base64.RawURLEncoding.EncodeToString(shortHash), nil
}

View File

@@ -1,190 +0,0 @@
package bootstrap
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"os"
"path/filepath"
"testing"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestMigrateKey(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
t.Run("no keys exist", func(t *testing.T) {
// Test when no keys exist
err := migrateKeyInternal(tempDir)
require.NoError(t, err)
})
t.Run("jwk already exists", func(t *testing.T) {
// Create a JWK file
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
key, err := createTestRSAKey()
require.NoError(t, err)
err = service.SaveKeyJWK(key, jwkPath)
require.NoError(t, err)
// Run migration - should do nothing
err = migrateKeyInternal(tempDir)
require.NoError(t, err)
// Check the file still exists
exists, err := utils.FileExists(jwkPath)
require.NoError(t, err)
assert.True(t, exists)
// Delete for next test
err = os.Remove(jwkPath)
require.NoError(t, err)
})
t.Run("migrate pem to jwk", func(t *testing.T) {
// Create a PEM file
pemPath := filepath.Join(tempDir, privateKeyFilePem)
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
// Generate RSA key and save as PEM
createRSAPrivateKeyPEM(t, pemPath)
// Run migration
err := migrateKeyInternal(tempDir)
require.NoError(t, err)
// Check PEM file is gone
exists, err := utils.FileExists(pemPath)
require.NoError(t, err)
assert.False(t, exists)
// Check JWK file exists
exists, err = utils.FileExists(jwkPath)
require.NoError(t, err)
assert.True(t, exists)
// Verify the JWK can be loaded
data, err := os.ReadFile(jwkPath)
require.NoError(t, err)
_, err = jwk.ParseKey(data)
require.NoError(t, err)
})
}
func TestLoadKeyPEM(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
t.Run("successfully load PEM key", func(t *testing.T) {
pemPath := filepath.Join(tempDir, "test_key.pem")
// Generate RSA key and save as PEM
createRSAPrivateKeyPEM(t, pemPath)
// Load the key
key, err := loadKeyPEM(pemPath)
require.NoError(t, err)
// Verify key properties
assert.NotEmpty(t, key)
// Check key ID is set
var keyID string
err = key.Get(jwk.KeyIDKey, &keyID)
require.NoError(t, err)
assert.NotEmpty(t, keyID)
// Check algorithm is set
var alg jwa.SignatureAlgorithm
err = key.Get(jwk.AlgorithmKey, &alg)
require.NoError(t, err)
assert.NotEmpty(t, alg)
// Check key usage is set
var keyUsage string
err = key.Get(jwk.KeyUsageKey, &keyUsage)
require.NoError(t, err)
assert.Equal(t, service.KeyUsageSigning, keyUsage)
})
t.Run("file not found", func(t *testing.T) {
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
require.Error(t, err)
assert.Nil(t, key)
})
t.Run("invalid file content", func(t *testing.T) {
invalidPath := filepath.Join(tempDir, "invalid.pem")
err := os.WriteFile(invalidPath, []byte("not a valid PEM"), 0600)
require.NoError(t, err)
key, err := loadKeyPEM(invalidPath)
require.Error(t, err)
assert.Nil(t, key)
})
}
func TestGenerateKeyID(t *testing.T) {
key, err := createTestRSAKey()
require.NoError(t, err)
keyID, err := generateKeyID(key)
require.NoError(t, err)
// Key ID should be non-empty
assert.NotEmpty(t, keyID)
// Generate another key ID to prove it depends on the key
key2, err := createTestRSAKey()
require.NoError(t, err)
keyID2, err := generateKeyID(key2)
require.NoError(t, err)
// The two key IDs should be different
assert.NotEqual(t, keyID, keyID2)
}
// Helper functions
func createTestRSAKey() (jwk.Key, error) {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, err
}
key, err := jwk.Import(privateKey)
if err != nil {
return nil, err
}
return key, nil
}
// createRSAPrivateKeyPEM generates an RSA private key and returns its PEM-encoded form
func createRSAPrivateKeyPEM(t *testing.T, pemPath string) ([]byte, *rsa.PrivateKey) {
// Generate RSA key
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
// Encode to PEM format
pemData := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
})
err = os.WriteFile(pemPath, pemData, 0600)
require.NoError(t, err)
return pemData, privKey
}

View File

@@ -0,0 +1,210 @@
package bootstrap
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"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 := slog.LevelDebug
if common.EnvConfig.AppEnv == "production" {
level = slog.LevelInfo
}
// Create the handler
var handler slog.Handler
switch {
case common.EnvConfig.LogJSON:
// Log as JSON if configured
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
case isatty.IsTerminal(os.Stdout.Fd()):
// Enable colors if we have a TTY
handler = tint.NewHandler(os.Stdout, &tint.Options{
TimeFormat: time.StampMilli,
Level: level,
})
default:
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
}
// 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
}

View File

@@ -1,107 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
"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"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName("pocket-id-backend"),
semconv.ServiceVersion(common.Version),
),
)
}
func initOtel(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()
if traces {
tr, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, 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{},
),
)
shutdownFns = append(shutdownFns, 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
})
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
} else {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
}
if metrics {
mr, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFns = append(shutdownFns, 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
})
} else {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
}
return shutdownFns, httpClient, nil
}

View File

@@ -2,17 +2,23 @@ package bootstrap
import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate"
"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"
@@ -26,7 +32,8 @@ var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *
func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc)
if err != nil {
log.Fatalf("failed to init router: %v", err)
slog.Error("Failed to init router", "error", err)
os.Exit(1)
}
return runner
}
@@ -42,11 +49,37 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
gin.SetMode(gin.TestMode)
}
r := gin.Default()
r.Use(gin.Logger())
// do not log these URLs
loggerSkipPathsPrefix := []string{
"GET /application-configuration/logo",
"GET /application-configuration/background-image",
"GET /application-configuration/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r := gin.New()
r.Use(sloggin.NewWithConfig(slog.Default(), sloggin.Config{
Filters: []sloggin.Filter{
func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return false
}
}
return true
},
},
}))
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware("pocket-id-backend"))
r.Use(otelgin.Middleware(common.Name))
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
@@ -55,6 +88,13 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
@@ -87,21 +127,39 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set up the server
srv := &http.Server{
Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port),
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: r,
}
// Set up the listener
listener, err := net.Listen("tcp", srv.Addr)
network := "tcp"
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
}
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
return nil, fmt.Errorf("failed to create TCP listener: %w", err)
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
}
// Set the socket mode if using a Unix socket
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
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)
}
}
// Service runner function
runFn := func(ctx context.Context) error {
log.Printf("Server listening on %s", srv.Addr)
slog.Info("Server listening", slog.String("addr", addr))
// Start the server in a background goroutine
go func() {
@@ -110,7 +168,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Next call blocks until the server is shut down
srvErr := srv.Serve(listener)
if srvErr != http.ErrServerClosed {
log.Fatalf("Error starting app server: %v", srvErr)
slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
}
}()
@@ -118,7 +177,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
err = systemd.SdNotifyReady()
if err != nil {
// Log the error only
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
}
// Block until the context is canceled
@@ -131,7 +190,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
slog.Warn("App server shutdown error", "error", shutdownErr)
}
return nil

View File

@@ -3,13 +3,14 @@ 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, scheduler *job.Scheduler) error {
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)
@@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche
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
}

View File

@@ -26,27 +26,41 @@ type services struct {
}
// Initializes all services
// The context should be used by services only for initialization, and not for running
func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(initCtx, db)
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
if err != nil {
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("unable to create email service: %w", err)
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 = service.NewJwtService(svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
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.oidcService = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
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)
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.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
return svc, nil
}

View 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)
}

View 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
}

View 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, "")
})
}
}

View 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)
}

View 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)
}
}

View 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)
},
})
}

View File

@@ -1,8 +1,13 @@
package common
import (
"log"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"reflect"
"strings"
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
@@ -18,73 +23,184 @@ const (
)
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"
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"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"`
SqliteDBPath string `env:"SQLITE_DB_PATH"` // Deprecated: use "DB_CONNECTION_STRING" instead
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` // Deprecated: use "DB_CONNECTION_STRING" instead
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
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"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
SqliteDBPath: "",
PostgresConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
AppURL: "http://localhost",
Port: "8080",
Host: "0.0.0.0",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
}
var EnvConfig = defaultConfig()
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
err := parseEnvConfig()
if err != nil {
slog.Error("Configuration error", slog.Any("error", err))
os.Exit(1)
}
}
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "",
UploadPath: "data/uploads",
KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: nil,
AppURL: "http://localhost:1411",
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,
}
}
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 = resolveFileBasedEnvVariables(&EnvConfig)
if err != nil {
return err
}
// Validate the environment variables
switch EnvConfig.DbProvider {
case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
EnvConfig.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil {
log.Fatal("PUBLIC_APP_URL is not a valid URL")
return errors.New("APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
log.Fatal("PUBLIC_APP_URL must not contain a path")
return errors.New("APP_URL must not contain a path")
}
switch EnvConfig.KeysStorage {
// KeysStorage defaults to "file" if empty
case "":
EnvConfig.KeysStorage = "file"
case "database":
if EnvConfig.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", EnvConfig.KeysStorage)
}
return nil
}
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
func resolveFileBasedEnvVariables(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)
// 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 {
continue
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" {
continue
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
continue
}
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 == "" {
continue
}
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
}

View File

@@ -0,0 +1,305 @@
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")
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)
})
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("DB_CONNECTION_STRING", "") // Explicitly empty
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 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", "127.0.0.1")
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)
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
})
}
func TestResolveFileBasedEnvVariables(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)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) {
config := defaultConfig()
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// Verify file contents were read correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
}

View File

@@ -65,11 +65,23 @@ type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
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{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
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{}
func (e *OidcInvalidCallbackURLError) Error() string {
@@ -156,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
@@ -344,3 +349,32 @@ func (e *OidcAuthorizationPendingError) Error() string {
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
}

View File

@@ -1,5 +1,8 @@
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.

View File

@@ -38,10 +38,10 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @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.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
@@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err)
return
}

View File

@@ -3,6 +3,7 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -57,7 +58,6 @@ type AppConfigController struct {
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Failure 500 {object} object "{"error": "error message"}"
// @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(false)
@@ -68,6 +68,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
return
}
// 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)
}
@@ -78,7 +85,6 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Security BearerAuth
// @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration := acc.appConfigService.ListAppConfig(true)
@@ -100,11 +106,10 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @Produce json
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
// @Success 200 {array} dto.AppConfigVariableDto
// @Security BearerAuth
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -157,7 +162,6 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
// @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")
@@ -170,7 +174,6 @@ func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
// @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.GetDbConfig().BackgroundImageType.Value
@@ -185,7 +188,6 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
// @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) {
dbConfig := acc.appConfigService.GetDbConfig()
@@ -211,7 +213,6 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
// @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")
@@ -235,7 +236,6 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
// @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.GetDbConfig().BackgroundImageType.Value
@@ -248,6 +248,8 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
c.File(imagePath)
}
@@ -273,7 +275,6 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll(c.Request.Context())
@@ -290,7 +291,6 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
// @Description Send a test email to verify email configuration
// @Tags Application Configuration
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")

View File

@@ -34,10 +34,10 @@ type AuditLogController struct {
// @Summary List audit logs
// @Description Get a paginated list of audit logs for the current user
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @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.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
@@ -82,13 +82,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Summary List all audit logs
// @Description Get a paginated list of all audit logs (admin only)
// @Tags Audit Logs
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @Param user_id query string false "Filter by user ID"
// @Param event query string false "Filter by event type"
// @Param client_name query string false "Filter by client name"
// @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) {

View File

@@ -35,10 +35,6 @@ type CustomClaimController struct {
// @Tags Custom Claims
// @Produce json
// @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]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
@@ -63,7 +59,7 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -93,12 +89,11 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Security BearerAuth
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}

View File

@@ -14,6 +14,10 @@ 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 {
@@ -21,19 +25,31 @@ type TestController struct {
}
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(); err != nil {
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(); 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 {
@@ -41,17 +57,71 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.SetLdapTestConfig(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
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)
}

View File

@@ -2,19 +2,20 @@ package controller
import (
"errors"
"log"
"log/slog"
"net/http"
"net/url"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"time"
"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/middleware"
"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/cookie"
)
// NewOidcController creates a new controller for OIDC related endpoints
@@ -48,9 +49,19 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
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 {
@@ -66,7 +77,6 @@ type OidcController struct {
// @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Security BearerAuth
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
@@ -84,6 +94,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
Issuer: common.EnvConfig.AppURL,
}
c.JSON(http.StatusOK, response)
@@ -97,7 +108,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
// @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Security BearerAuth
// @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
@@ -121,11 +131,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Tags OIDC
// @Produce json
// @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 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 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"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
@@ -136,13 +148,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
}
// 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{})
return
}
// 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{})
return
}
@@ -152,8 +164,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
idToken, accessToken, refreshToken, expiresIn, err :=
oc.oidcService.CreateTokens(c.Request.Context(), input)
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
@@ -171,23 +182,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
return
}
response := dto.OidcTokenResponseDto{
AccessToken: accessToken,
TokenType: "Bearer",
ExpiresIn: expiresIn,
}
// 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)
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
AccessToken: tokens.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
IdToken: tokens.IdToken, // May be empty
RefreshToken: tokens.RefreshToken, // May be empty
})
}
// userInfoHandler godoc
@@ -206,7 +207,7 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
return
}
token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil {
_ = c.Error(err)
return
@@ -235,7 +236,6 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
// @Description End user session and handle OIDC logout
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint query string false "ID token"
// @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"
@@ -262,7 +262,7 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
// 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")
return
}
@@ -315,9 +315,21 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
// 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).
clientID, clientSecret, _ := c.Request.BasicAuth()
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(), clientID, clientSecret, input.Token)
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
if err != nil {
_ = c.Error(err)
return
@@ -359,7 +371,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
@@ -371,12 +382,12 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
if err != nil {
_ = c.Error(err)
return
}
_ = c.Error(err)
c.JSON(http.StatusOK, clientDto)
}
// listClientsHandler godoc
@@ -384,12 +395,11 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Description Get a paginated list of OIDC clients with optional search and sorting
// @Tags OIDC
// @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 limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
// @Security BearerAuth
// @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.OidcClientWithAllowedGroupsCountDto]
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
@@ -405,13 +415,23 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
_ = c.Error(err)
return
// Map the user groups to DTOs
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i, client := range clients {
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,
Pagination: pagination,
})
@@ -425,7 +445,6 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
// @Produce json
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Security BearerAuth
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
@@ -455,7 +474,6 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
@@ -474,12 +492,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Accept json
// @Produce json
// @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"
// @Security BearerAuth
// @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
var input dto.OidcClientUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
@@ -507,7 +524,6 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
@@ -536,6 +552,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
return
}
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
@@ -546,9 +564,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Tags OIDC
// @Accept multipart/form-data
// @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"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
@@ -572,7 +589,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
@@ -593,7 +609,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
// @Param id path string true "Client ID"
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
@@ -638,6 +653,114 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
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 == "" {
@@ -673,3 +796,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
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, scopes)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, preview)
}

View File

@@ -7,7 +7,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"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/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -15,6 +14,11 @@ import (
"golang.org/x/time/rate"
)
const (
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
// NewUserController creates a new controller for user management endpoints
// @Summary User management controller
// @Description Initializes all user-related API endpoints
@@ -45,11 +49,17 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
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/setup", uc.getSetupAccessTokenHandler)
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
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 {
@@ -86,10 +96,10 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Description Get a paginated list of users with optional search and sorting
// @Tags Users
// @Param search query string false "Search term to filter users"
// @Param page query int false "Page number, starting from 1" default(1)
// @Param limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("created_at")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
// @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.UserDto]
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
@@ -188,7 +198,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
// @Router /api/users [post]
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -228,10 +238,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
_ = c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true)
}
@@ -255,10 +261,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
defer picture.Close()
}
_, ok := c.GetQuery("skipCache")
if !ok {
c.Header("Cache-Control", "public, max-age=900")
}
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
@@ -333,10 +336,17 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
return
}
var ttl time.Duration
if own {
input.UserID = c.GetString("userID")
ttl = defaultOneTimeAccessTokenDuration
} else {
ttl = input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
}
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil {
_ = c.Error(err)
return
@@ -380,7 +390,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
// @Router /api/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -413,7 +423,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
userID := c.Param("id")
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
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
@@ -448,14 +462,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
c.JSON(http.StatusOK, userDto)
}
// getSetupAccessTokenHandler godoc
// @Summary Setup initial admin
// @Description Generate setup access token for initial admin user configuration
// signUpInitialAdmin godoc
// @Summary Sign up initial admin user
// @Description Sign up and generate setup access token for initial admin user
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.SignUpDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/setup [post]
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
// @Router /api/signup/setup [post]
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
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 {
_ = c.Error(err)
return
@@ -503,10 +526,138 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
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
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}

View File

@@ -40,10 +40,10 @@ type UserGroupController struct {
// @Description Get a paginated list of user groups with optional search and sorting
// @Tags User Groups
// @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 limit query int false "Number of items per page" default(10)
// @Param sort_column query string false "Column to sort by" default("name")
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
// @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.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
@@ -92,7 +92,6 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Security BearerAuth
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -118,11 +117,10 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Security BearerAuth
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -151,11 +149,10 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Security BearerAuth
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err)
return
}
@@ -183,7 +180,6 @@ func (ugc *UserGroupController) update(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Success 204 "No Content"
// @Security BearerAuth
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
@@ -203,7 +199,6 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Security BearerAuth
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto

View File

@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
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.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "")
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})
}

View File

@@ -3,8 +3,9 @@ package controller
import (
"encoding/json"
"fmt"
"log"
"log/slog"
"net/http"
"os"
"github.com/gin-gonic/gin"
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
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)
@@ -69,20 +72,22 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
}
config := map[string]any{
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"},
"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()},
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
"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)
}

View File

@@ -5,15 +5,15 @@ import (
)
type ApiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50"`
Description string `json:"description"`
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
Description *string `json:"description" unorm:"nfc"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Description *string `json:"description"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
CreatedAt datatype.DateTime `json:"createdAt"`

View File

@@ -12,11 +12,15 @@ type AppConfigVariableDto 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"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`

View File

@@ -1,7 +1,6 @@
package dto
import (
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -9,18 +8,19 @@ type AuditLogDto struct {
ID string `json:"id"`
CreatedAt datatype.DateTime `json:"createdAt"`
Event model.AuditLogEvent `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
Username string `json:"username"`
Data model.AuditLogData `json:"data"`
Event string `json:"event"`
IpAddress string `json:"ipAddress"`
Country string `json:"country"`
City string `json:"city"`
Device string `json:"device"`
UserID string `json:"userID"`
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]"`
}

View File

@@ -6,6 +6,6 @@ type CustomClaimDto struct {
}
type CustomClaimCreateDto struct {
Key string `json:"key" binding:"required"`
Value string `json:"value" binding:"required"`
Key string `json:"key" binding:"required" unorm:"nfc"`
Value string `json:"value" binding:"required" unorm:"nfc"`
}

View File

@@ -1,109 +1,27 @@
package dto
import (
"errors"
"reflect"
"time"
"fmt"
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
func MapStructList[S any, D any](source []S, destination *[]D) error {
*destination = make([]D, 0, len(source))
func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
*destination = make([]D, len(source))
for _, item := range source {
var destItem D
if err := MapStruct(item, &destItem); err != nil {
return err
for i, item := range source {
err = MapStruct(item, &((*destination)[i]))
if err != nil {
return fmt.Errorf("failed to map field %d: %w", i, err)
}
*destination = append(*destination, destItem)
}
return nil
}
// MapStruct maps a source struct to a destination struct
func MapStruct[S any, D any](source S, destination *D) error {
// Ensure destination is a non-nil pointer
destValue := reflect.ValueOf(destination)
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 {
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
continue
}
sourceField := sourceVal.FieldByName(destFieldType.Name)
if sourceField.IsValid() && destField.CanSet() {
if err := mapField(sourceField, destField); err != nil {
return err
}
}
}
return nil
}
func mapField(sourceField reflect.Value, destField reflect.Value) error {
switch {
case sourceField.Type() == destField.Type():
destField.Set(sourceField)
case sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice:
return mapSlice(sourceField, destField)
case sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct:
return mapStructInternal(sourceField, destField)
default:
return mapSpecialTypes(sourceField, destField)
}
return nil
}
func mapSlice(sourceField reflect.Value, destField reflect.Value) error {
if sourceField.Type().Elem() == destField.Type().Elem() {
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 {
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
return nil
}
func mapSpecialTypes(sourceField reflect.Value, destField reflect.Value) error {
if _, ok := sourceField.Interface().(datatype.DateTime); ok {
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
func MapStruct(source any, destination any) error {
return copier.CopyWithOption(destination, source, copier.Option{
DeepCopy: true,
})
}

View 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)
}

View 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
}

View 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)
})
}

View File

@@ -1,17 +1,22 @@
package dto
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
LaunchURL *string `json:"launchURL"`
RequiresReauthentication bool `json:"requiresReauthentication"`
}
type OidcClientDto struct {
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
}
type OidcClientWithAllowedUserGroupsDto struct {
@@ -19,26 +24,52 @@ type OidcClientWithAllowedUserGroupsDto struct {
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"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
OidcClientUpdateDto
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
}
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 {
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
}
type AuthorizeOidcClientResponseDto struct {
Code string `json:"code"`
CallbackURL string `json:"callbackURL"`
Issuer string `json:"issuer"`
}
type AuthorizationRequiredDto struct {
@@ -47,13 +78,15 @@ type AuthorizationRequiredDto struct {
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcIntrospectDto struct {
@@ -93,9 +126,11 @@ type OidcIntrospectionResponseDto struct {
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
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 {
@@ -120,3 +155,20 @@ type DeviceCodeInfoDto struct {
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"`
}

View 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"`
}

View File

@@ -1,6 +1,8 @@
package dto
import "time"
import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserDto struct {
ID string `json:"id"`
@@ -17,10 +19,10 @@ type UserDto struct {
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"max=50"`
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
@@ -28,19 +30,27 @@ type UserCreateDto struct {
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email"`
Email string `json:"email" binding:"required,email" unorm:"nfc"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
}
type UserUpdateUserGroupDto struct {
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:"required,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"`
}

View File

@@ -34,8 +34,8 @@ type UserGroupDtoWithUserCount struct {
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
Name string `json:"name" binding:"required,min=2,max=255"`
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
LdapID string `json:"-"`
}

View File

@@ -1,26 +1,52 @@
package dto
import (
"log"
"regexp"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
func init() {
v := binding.Validator.Engine().(*validator.Validate)
// [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 username must end with an alphanumeric character
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
// Errors here are development-time ones
err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return validateUsernameRegex.MatchString(fl.Field().String())
})
if err != nil {
panic("Failed to register custom validation for username: " + err.Error())
}
err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return validateClientIDRegex.MatchString(fl.Field().String())
})
if err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
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
})
if err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
}

View File

@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
}
type WebauthnCredentialUpdateDto struct {
Name string `json:"name" binding:"required,min=1,max=30"`
Name string `json:"name" binding:"required,min=1,max=50"`
}

View 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
}

View File

@@ -2,7 +2,10 @@ package job
import (
"context"
"log"
"fmt"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -18,7 +21,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
appConfigService: appConfigService,
}
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
// 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 {
@@ -29,16 +33,16 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
if err != nil {
log.Printf("Failed to list expiring API keys: %v", err)
return err
return fmt.Errorf("failed to list expiring API keys: %w", err)
}
for _, key := range apiKeys {
if key.User.Email == "" {
continue
}
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil {
log.Printf("Failed to send email for key %s: %v", key.ID, err)
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

View File

@@ -3,8 +3,11 @@ 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"
@@ -14,12 +17,16 @@ import (
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", "0 3 * * *", jobs.clearWebauthnSessions),
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes),
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens),
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs),
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),
)
}
@@ -29,40 +36,99 @@ type DbCleanupJobs struct {
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
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 {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
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 {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
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 {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
Error
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 {
return j.db.
st := j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).
Error
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
}

View File

@@ -3,11 +3,13 @@ package job
import (
"context"
"fmt"
"log"
"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"
@@ -17,7 +19,8 @@ import (
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db}
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures)
// Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
}
type FileCleanupJobs struct {
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil {
log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err)
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else {
filesDeleted++
}
}
}
log.Printf("Deleted %d unused default profile pictures", filesDeleted)
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil
}

View File

@@ -2,9 +2,10 @@ package job
import (
"context"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -21,23 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Register the job to run every day, at 5 minutes past midnight
err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB)
if err != nil {
return err
}
// Run the job immediately on startup, with a 1s delay
go func() {
time.Sleep(time.Second)
err = jobs.updateGoeLiteDB(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to Update GeoLite database: %v", err)
}
}()
return nil
// 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 {

View File

@@ -2,7 +2,9 @@ package job
import (
"context"
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -16,19 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour
err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap)
if err != nil {
return err
}
// Run the job immediately on startup
err = jobs.syncLdap(ctx)
if err != nil {
// Log the error only, but don't return it
log.Printf("Failed to sync LDAP: %v", err)
}
return nil
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -3,7 +3,7 @@ package job
import (
"context"
"fmt"
"log"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
// Run the scheduler.
// This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error {
log.Println("Starting job scheduler")
slog.Info("Starting job scheduler")
s.scheduler.Start()
// Block until context is canceled
@@ -35,28 +35,45 @@ func (s *Scheduler) Run(ctx context.Context) error {
err := s.scheduler.Shutdown()
if err != nil {
log.Printf("[WARN] Error shutting down job scheduler: %v", err)
slog.Error("Error shutting down job scheduler", slog.Any("error", err))
} else {
log.Println("Job scheduler shut down")
slog.Info("Job scheduler shut down")
}
return nil
}
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error) error {
_, err := s.scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
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) {
log.Printf("Job %q run successfully", name)
slog.Info("Job run successfully",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err)
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)

View File

@@ -26,6 +26,7 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
}
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

View File

@@ -29,7 +29,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
// Skip rate limiting for localhost and test environment
// If the client ip is localhost the request comes from the frontend
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
c.Next()
return
}

View File

@@ -4,9 +4,12 @@ import (
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type AppConfigVariable struct {
@@ -31,21 +34,26 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
type AppConfig struct {
// General
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"`
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
// Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
@@ -56,7 +64,7 @@ type AppConfig struct {
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
LdapBase AppConfigVariable `key:"ldapBase"`
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
@@ -74,7 +82,7 @@ type AppConfig struct {
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
}
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
// Use reflection to iterate through all fields
cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type()
@@ -94,11 +102,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
continue
}
fieldValue := cfgValue.Field(i)
value := cfgValue.Field(i).FieldByName("Value").String()
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
value = "XXXXXXXXXX"
}
appConfigVariable := AppConfigVariable{
Key: key,
Value: fieldValue.FieldByName("Value").String(),
Value: value,
}
res = append(res, appConfigVariable)
@@ -107,24 +120,26 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
return res
}
func (c *AppConfig) FieldByKey(key string) (string, error) {
func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches
for i := range rt.NumField() {
// Grab only the first part of the key, if there's a comma with additional properties
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
if tagValue != key {
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
keyFromTag := tagValue[0]
isInternal = slices.Contains(tagValue, "internal")
if keyFromTag != key {
continue
}
valueField := rv.Field(i).FieldByName("Value")
return valueField.String(), nil
return valueField.String(), isInternal, nil
}
// If we are here, the config key was not found
return "", AppConfigKeyNotFoundError{field: key}
return "", false, AppConfigKeyNotFoundError{field: key}
}
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
@@ -165,7 +180,7 @@ type AppConfigKeyNotFoundError struct {
}
func (e AppConfigKeyNotFoundError) Error() string {
return fmt.Sprintf("cannot find config key '%s'", e.field)
return "cannot find config key '" + e.field + "'"
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
@@ -179,7 +194,7 @@ type AppConfigInternalForbiddenError struct {
}
func (e AppConfigInternalForbiddenError) Error() string {
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
return "field '" + e.field + "' is internal and can't be updated"
}
func (e AppConfigInternalForbiddenError) Is(target error) bool {

View File

@@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") {
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
// Skip internal fields that shouldn't be in the DTO
continue
}

View File

@@ -10,7 +10,7 @@ type AuditLog struct {
Base
Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"`
IpAddress *string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"

View File

@@ -0,0 +1,11 @@
package model
type KV struct {
Key string `gorm:"primaryKey;not null"`
Value *string
}
// TableName overrides the table name used by KV to `kv`
func (KV) TableName() string {
return "kv"
}

View File

@@ -5,12 +5,15 @@ import (
"encoding/json"
"fmt"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserAuthorizedOidcClient struct {
Scope string
Scope string
LastUsedAt datatype.DateTime `sortable:"true"`
UserID string `gorm:"primary_key;"`
User User
@@ -37,18 +40,22 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
RequiresReauthentication bool
Credentials OidcClientCredentials
LaunchURL *string
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string
CreatedBy User
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string
CreatedBy *User
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
}
type OidcRefreshToken struct {
@@ -71,9 +78,49 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
return nil
}
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentity struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
}
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
if issuer == "" {
return OidcClientFederatedIdentity{}, false
}
for _, fi := range occ.FederatedIdentities {
if fi.Issuer == issuer {
return fi, true
}
}
return OidcClientFederatedIdentity{}, false
}
func (occ *OidcClientCredentials) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, occ)
case string:
return json.Unmarshal([]byte(v), occ)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}
func (occ OidcClientCredentials) Value() (driver.Value, error) {
return json.Marshal(occ)
}
type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value interface{}) error {
func (cu *UrlList) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, cu)

View File

@@ -0,0 +1,28 @@
package model
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type SignupToken struct {
Base
Token string `json:"token"`
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"`
}
func (st *SignupToken) IsExpired() bool {
return time.Time(st.ExpiresAt).Before(time.Now())
}
func (st *SignupToken) IsUsageLimitReached() bool {
return st.UsageCount >= st.UsageLimit
}
func (st *SignupToken) IsValid() bool {
return !st.IsExpired() && !st.IsUsageLimitReached()
}

View File

@@ -2,6 +2,7 @@ package datatype
import (
"database/sql/driver"
"fmt"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -10,9 +11,16 @@ import (
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
type DateTime time.Time //nolint:recvcheck
func (date *DateTime) Scan(value interface{}) (err error) {
*date = DateTime(value.(time.Time))
return
func (date *DateTime) Scan(value any) (err error) {
switch v := value.(type) {
case time.Time:
*date = DateTime(v)
case int64:
*date = DateTime(time.Unix(v, 0))
default:
return fmt.Errorf("unexpected type for DateTime: %T", value)
}
return nil
}
func (date DateTime) Value() (driver.Value, error) {

View File

@@ -5,6 +5,7 @@ import (
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)

View File

@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
Timeout time.Duration
}
type ReauthenticationToken struct {
Base
Token string
ExpiresAt datatype.DateTime
UserID string
User User
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type

Some files were not shown because too many files have changed in this diff Show More