Compare commits

...

315 Commits

Author SHA1 Message Date
Elias Schneider
96aa2ce043 Merge branch 'breaking/v2' into v2/use-item-component 2025-11-29 18:42:28 +01:00
Kyle Mendell
e06538a101 feat: remove DbProvider env variable and calculate it dynamically (#1114) 2025-11-27 12:38:06 -08:00
Kyle Mendell
ddff3a2975 fixes 2025-11-26 13:55:49 -06:00
Kyle Mendell
a738d9fe88 refactor: update forms and other areas to use new shadcn components 2025-11-26 12:58:44 -06:00
Elias Schneider
39c1f93756 feat: add CLI command for importing and exporting Pocket ID data (#998)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-26 10:38:15 +01:00
ItalyPaleAle
e306d6eb58 Merge remote-tracking branch 'origin' into breaking/v2 2025-11-25 20:48:03 -08:00
Elias Schneider
f523f39483 tests: fix Dutch validation message 2025-11-25 22:51:20 +01:00
Elias Schneider
4bde271b47 chore: upgrade dependencies 2025-11-25 22:30:28 +01:00
Elias Schneider
a3c968758a feat: add option to disable S3 integrity check 2025-11-25 22:14:44 +01:00
Elias Schneider
ca888b3dd2 chore(translations): add Finish files 2025-11-25 20:46:48 +01:00
Elias Schneider
ce88686c5f chore(translations): update translations via Crowdin (#1111) 2025-11-25 20:43:47 +01:00
Elias Schneider
a9b6635126 chore(translations): update translations via Crowdin (#1101) 2025-11-23 17:10:24 +01:00
dependabot[bot]
e817f042ec chore(deps): bump golang.org/x/crypto from 0.43.0 to 0.45.0 in /backend in the go_modules group across 1 directory (#1107)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-23 17:10:10 +01:00
Elias Schneider
46793fe68a Merge branch 'main' into v2 2025-11-17 08:39:36 +01:00
Alessandro (Ale) Segala
c56afe016e feat: adding/removing passkeys creates an entry in audit logs (#1099)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-16 14:51:38 -08:00
Alessandro (Ale) Segala
a54b867105 refactor: use constants for AppEnv values (#1098) 2025-11-16 18:25:06 +01:00
Alessandro (Ale) Segala
29a1d3b778 feat: add database storage backend (#1091)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-16 18:23:46 +01:00
Elias Schneider
e22822890f feat!: drop support for storing JWK on the filesystem (#1088) 2025-11-14 12:02:51 +01:00
Elias Schneider
2694d79add Merge branch 'main' into v2 2025-11-14 11:08:20 +01:00
Elias Schneider
98cf1f66c3 fix!: rename LDAP_ATTRIBUTE_ADMIN_GROUP env variable to LDAP_ADMIN_GROUP_NAME (#1089) 2025-11-12 11:51:01 +01:00
Elias Schneider
12125713a2 feat: add support for WEBP profile pictures (#1090) 2025-11-11 10:56:20 -06:00
Elias Schneider
ab9c0f9ac0 ci/cd: run checks on PR to breaking/** branches 2025-11-11 11:21:39 +01:00
Elias Schneider
42b872d6b2 chore(translations): update translations via Crowdin (#1085) 2025-11-10 14:46:48 +01:00
Elias Schneider
bfd71d090c feat: add support for S3 storage backend (#1080)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-11-10 09:02:25 +00:00
Kyle Mendell
d5e0cfd4a6 feat: light/dark/system mode switcher (#1081) 2025-11-09 13:28:58 -06:00
Kyle Mendell
9981304b4b chore(deps): update pnpm to 10.20 (#1082) 2025-11-08 13:09:05 -06:00
Elias Schneider
5cf73e9309 fix: use quoted-printable encoding for mails to prevent line limitation 2025-11-08 17:34:43 +01:00
Elias Schneider
f125cf0dad release: 1.15.0 2025-11-06 15:49:39 +01:00
Elias Schneider
6a038fcf9a fix: remove redundant indexes in Postgres 2025-11-06 15:02:39 +01:00
Elias Schneider
76e0192cee fix: disabled property gets ignored when creating an user 2025-11-06 12:28:07 +01:00
Elias Schneider
3ebf94dd84 chore(translations): update translations via Crowdin (#1059) 2025-11-05 11:28:14 +01:00
dai
7ec57437ac fix: replace %lang% placeholder in html lang (#1071)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-04 13:01:12 +00:00
Elias Schneider
ed2c7b2303 feat: add ability to set default profile picture (#1061)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-11-04 13:40:00 +01:00
Elias Schneider
e03270eb9d fix: sorting by PKCE and re-auth of OIDC clients 2025-11-04 13:27:05 +01:00
Elias Schneider
d683d18d91 chore: add support for OpenBSD binaries 2025-11-01 16:26:43 +01:00
Elias Schneider
f184120890 feat: open edit page on table row click 2025-10-29 10:46:03 +01:00
Elias Schneider
04d8500910 release: 1.14.2 2025-10-29 09:25:28 +01:00
Mufeed Ali
93639dddb2 fix: dark oidc client icons not saved on client creation (#1057) 2025-10-28 12:35:56 +01:00
Elias Schneider
a190529117 chore(translations): add Turkish language files 2025-10-28 09:31:32 +01:00
Elias Schneider
73392b5837 release: 1.14.1 2025-10-27 14:07:34 +01:00
Elias Schneider
65616f65e5 fix: ignore trailing slashes in APP_URL 2025-10-27 10:48:27 +01:00
Elias Schneider
98a99fbb0a chore(translations): update translations via Crowdin (#1048) 2025-10-27 09:48:39 +01:00
Quentin L'Hours
3f3b6b88fd fix: use credProps to save passkey on firefox android (#1055) 2025-10-27 09:48:24 +01:00
Mufeed Ali
8f98d8c0b4 fix: Prevent blinding FOUC in dark mode (#1054) 2025-10-26 20:40:25 +01:00
Elias Schneider
c9308472a9 release: 1.14.0 2025-10-24 13:47:58 +02:00
Elias Schneider
6362ff9861 chore: upgrade dependencies 2025-10-24 12:18:38 +02:00
Elias Schneider
10d640385f fix: prevent page flickering on redirection based on auth state 2025-10-24 12:12:42 +02:00
Elias Schneider
47927d1574 fix: make pkce requirement visible in the oidc form if client is public 2025-10-24 10:57:44 +02:00
Elias Schneider
b356cef766 fix: only animate login background on initial page load 2025-10-24 10:53:51 +02:00
Elias Schneider
9fc45930a8 chore(translations): update translations via Crowdin (#1033) 2025-10-24 09:58:00 +02:00
Kyle Mendell
028d1c858e feat: add support for dark mode oidc client icons (#1039)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-10-24 09:57:12 +02:00
Alessandro (Ale) Segala
eb3963d0fc fix: use constant time comparisons when validating PKCE challenges (#1047) 2025-10-24 08:30:50 +02:00
dependabot[bot]
35d913f905 chore(deps-dev): bump vite from 7.0.7 to 7.0.8 in the npm_and_yarn group across 1 directory (#1042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 18:04:28 -05:00
github-actions[bot]
32485f4c7c chore: update AAGUIDs (#1041)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-10-20 08:43:27 +02:00
Elias Schneider
ceb38b0825 chore(translations): update translations via Crowdin (#1025) 2025-10-16 08:28:13 +02:00
dependabot[bot]
c0b6ede5be chore(deps): bump sveltekit-superforms from 2.27.1 to 2.27.4 in the npm_and_yarn group across 1 directory (#1031)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 08:27:52 +02:00
Elias Schneider
c20e93b55c feat: add various improvements to the table component (#961)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-10-13 09:12:55 +00:00
Elias Schneider
24ca6a106d chore(translations): update translations via Crowdin (#1014) 2025-10-12 09:58:22 -05:00
Elias Schneider
9f0aa55be6 fix: ignore trailing slash in URL 2025-10-09 20:27:15 +02:00
Kyle Mendell
068fcc65a6 chore(translations): add Japanese files 2025-10-07 18:29:13 -05:00
Elias Schneider
f2dfb3da5d release: 1.13.1 2025-10-07 08:21:41 +02:00
Elias Schneider
cbf0e3117d fix: mark any callback url as valid if they contain a wildcard (#1006) 2025-10-07 08:18:53 +02:00
CzBiX
694f266dea fix: uploading a client logo with an URL fails (#1008) 2025-10-06 10:37:43 -05:00
Kyle Mendell
29fc185376 chore: cleanup root of repo, update workflow actions (#1003)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-10-05 14:49:06 -05:00
Elias Schneider
781be37416 release: 1.13.0 2025-10-05 17:30:47 +02:00
Elias Schneider
b1f97e05a1 chore(translations): update translations via Crowdin (#999) 2025-10-05 17:30:08 +02:00
Elias Schneider
2c74865173 feat: add link to API docs on API key page 2025-10-04 23:45:37 +02:00
Elias Schneider
ad8a90c839 fix: uploading a client logo with an URL fails if folder doesn't exist 2025-10-04 23:44:37 +02:00
Elias Schneider
f9839a978c release: 1.12.0 2025-10-03 11:59:38 +02:00
Elias Schneider
b81de45166 fix: date locale can't be loaded if locale is en 2025-10-03 11:54:07 +02:00
Elias Schneider
22f4254932 fix: allow any image source but disallow base64 2025-10-03 11:50:39 +02:00
Elias Schneider
507f9490fa feat: add the ability to make email optional (#994) 2025-10-03 11:24:53 +02:00
Elias Schneider
043cce615d feat: add required indicator for required inputs (#993) 2025-10-01 13:44:17 +02:00
Elias Schneider
69e2083722 chore(translations): update translations via Crowdin (#992) 2025-09-30 23:04:54 -05:00
Elias Schneider
d47b20326f fix: improve back button handling on auth pages 2025-09-30 14:44:08 +02:00
Elias Schneider
fc9939d1f1 fix: prevent endless effect loop in login wrapper 2025-09-30 14:06:13 +02:00
Elias Schneider
2c1c67b5e4 fix: include port in OIDC client details 2025-09-30 12:18:44 +02:00
Elias Schneider
d010be4c88 feat: hide alternative sign in methods page if email login disabled 2025-09-30 12:15:08 +02:00
Elias Schneider
01db8c0a46 fix: make logo and oidc client images sizes consistent 2025-09-30 12:12:37 +02:00
Alessandro (Ale) Segala
fe5917d96d fix: tokens issued with refresh token flow don't contain groups (#989)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-30 09:44:38 +00:00
Elias Schneider
4f0b434c54 chore(translations): update translations via Crowdin (#973) 2025-09-30 11:39:47 +02:00
Kyle Mendell
6bdf5fa37a feat: support for url based icons (#840)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-29 15:07:55 +00:00
Caian Benedicto
47bd5ba1ba fix: remove previous socket file to prevent bind error (#979) 2025-09-24 10:10:05 +00:00
Elias Schneider
b746ac0835 chore(email): remove unnecessary logo fallback 2025-09-24 12:08:24 +02:00
Elias Schneider
79989fb176 fix(email): display login location correctly if country or city is not present 2025-09-24 12:01:20 +02:00
Clément Contini
ecc7e224e9 fix: show only country in audit log location if no city instead of Unknown (#977) 2025-09-23 18:41:54 -05:00
Alessandro (Ale) Segala
549d219f44 fix(unit-tests): do not use cache=shared for in-memory SQLite (#971) 2025-09-21 19:32:41 -05:00
github-actions[bot]
ffe18db2fb chore: update AAGUIDs (#972)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-21 19:31:44 -05:00
Elias Schneider
e8b172f1c3 chore(release notes): fix whitespace after commit message 2025-09-20 22:54:55 +02:00
Elias Schneider
097bda349a release: 1.11.2 2025-09-20 22:05:21 +02:00
Elias Schneider
6e24517197 chore(translations): update translations via Crowdin (#963) 2025-09-20 21:43:03 +02:00
Elias Schneider
a3da943aa6 fix: decouple images from app config service (#965)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-09-20 21:42:52 +02:00
Alessandro (Ale) Segala
cc34aca2a0 fix: do not treat certain failures in app images bootstrap as fatal (#966) 2025-09-20 21:32:00 +02:00
Elias Schneider
fde4e9b38a chore: use git cliff for release notes 2025-09-20 21:30:44 +02:00
Elias Schneider
c55143d8c9 fix: embedded paths not found on windows 2025-09-19 13:43:49 +02:00
Elias Schneider
8973e93cb6 release: 1.11.1 2025-09-18 22:33:22 +02:00
Elias Schneider
8c9cac2655 chore(translations): update translations via Crowdin (#957) 2025-09-18 22:26:38 +02:00
Elias Schneider
ed8547ccc1 release: 1.11.0 2025-09-18 22:16:32 +02:00
Elias Schneider
e7e53a8b8c fix: my apps card shouldn't take full width if only one item exists 2025-09-18 21:55:43 +02:00
Elias Schneider
02249491f8 feat: allow uppercase usernames (#958) 2025-09-17 14:43:12 -05:00
Elias Schneider
cf0892922b chore: include version in changelog 2025-09-17 18:00:04 +02:00
Elias Schneider
99f31a7c26 fix: make environment variables case insensitive where necessary (#954)
fix #935
2025-09-17 08:21:54 -07:00
Kyle Mendell
68373604dd feat: add user display name field (#898)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-17 17:18:27 +02:00
Elias Schneider
2d6d5df0e7 feat: add support for LOG_LEVEL env variable (#942) 2025-09-14 08:26:21 -07:00
Alessandro (Ale) Segala
a897b31166 chore: minify background image (#933)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-14 08:24:28 -07:00
dependabot[bot]
fb92906c3a chore(deps): bump axios from 1.11.0 to 1.12.0 in the npm_and_yarn group across 1 directory (#943)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 12:20:18 -05:00
Alessandro (Ale) Segala
c018f29ad7 fix: key-rotate doesn't work with database storage (#940) 2025-09-12 20:04:45 -05:00
Elias Schneider
5367463239 feat: add PWA support (#938) 2025-09-12 10:17:35 -05:00
Elias Schneider
6c9147483c fix: add validation for callback URLs (#929) 2025-09-10 10:14:54 -07:00
Elias Schneider
d123d7f335 chore(translations): update translations via Crowdin (#931) 2025-09-10 07:57:58 -05:00
dependabot[bot]
da8ca08c36 chore(deps-dev): bump vite from 7.0.6 to 7.0.7 in the npm_and_yarn group across 1 directory (#932)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-09 17:32:14 -05:00
Alessandro (Ale) Segala
307caaa3ef feat: return new id_token when using refresh token (#925) 2025-09-09 11:31:50 +02:00
Elias Schneider
6c696b46c8 fix: list items on previous page get unselected if other items selected on next page 2025-09-09 10:02:59 +02:00
Alessandro (Ale) Segala
42155238b7 fix: ensure users imported from LDAP have fields validated (#923) 2025-09-09 09:31:49 +02:00
Elias Schneider
92edc26a30 chore(translations): update translations via Crowdin (#924) 2025-09-08 08:12:21 -05:00
github-actions[bot]
e36499c483 chore: update AAGUIDs (#926)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-08 00:14:56 -05:00
Elias Schneider
6215e1ac01 feat: add CSP header (#908)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-09-07 11:45:06 -07:00
Elias Schneider
74b39e16f9 chore(translations): update translations via Crowdin (#915) 2025-09-07 20:31:30 +02:00
Elias Schneider
a1d8538c64 feat: add info box to app settings if UI config is disabled 2025-09-07 19:49:07 +02:00
Elias Schneider
1d7cbc2a4e fix: disable sign up options in UI if UI_CONFIG_DISABLED 2025-09-07 19:42:20 +02:00
Kyle Mendell
954fb4f0c8 chore(translations): add Swedish files 2025-09-05 19:57:54 -05:00
Savely Krasovsky
901333f7e4 feat: client_credentials flow support (#901) 2025-09-02 18:33:01 -05:00
Elias Schneider
0b381467ca chore(translations): update translations via Crowdin (#904) 2025-09-02 09:57:31 -05:00
github-actions[bot]
6188dc6fb7 chore: update AAGUIDs (#903)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-31 19:40:23 -05:00
Kyle Mendell
802754c24c refactor: use react email for email templates (#734)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-31 16:54:13 +00:00
Elias Schneider
6c843228eb chore(translations): update translations via Crowdin (#893) 2025-08-30 13:20:35 -05:00
Stephan H.
a3979f63e0 feat: add custom base url (#858)
Co-authored-by: Stephan Höhn <me@steph.ovh>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-30 13:13:57 -05:00
Elias Schneider
52c560c30d chore(translations): update translations via Crowdin (#887)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-08-27 16:44:26 -05:00
Kyle Mendell
e88be7e61a fix: update localized name and description of ldap group name attribute (#892) 2025-08-27 15:52:50 -05:00
Kyle Mendell
a4e965434f release: 1.10.0 2025-08-27 15:24:57 -05:00
Kyle Mendell
096d214a88 feat: redesigned sidebar with administrative dropdown (#881)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-27 16:39:22 +00:00
Savely Krasovsky
afb7fc32e7 chore(translations): add missing translations (#884) 2025-08-27 18:13:35 +02:00
Elias Schneider
641bbc9351 fix: apps showed multiple times if user is in multiple groups 2025-08-27 17:53:21 +02:00
Kyle Mendell
136c6082f6 chore(deps): bump sveltekit to 2.36.3 and devalue to 5.3.2 (#889) 2025-08-26 18:59:35 -05:00
github-actions[bot]
b9a20d2923 chore: update AAGUIDs (#885)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-25 08:13:32 +02:00
Elias Schneider
74eb2ac0b9 release: 1.9.1 2025-08-24 23:17:31 +02:00
Elias Schneider
51222f5607 tests: add no tx wrap to unit tests 2025-08-24 23:16:49 +02:00
Elias Schneider
d6d1a4ced2 fix: sqlite migration drops allowed user groups 2025-08-24 23:07:50 +02:00
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
563 changed files with 36275 additions and 14757 deletions

View File

@@ -1,4 +1,4 @@
node_modules
**/node_modules
# Output
.output

View File

@@ -1,5 +1,17 @@
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
# These variables must be configured for your deployment:
APP_URL=https://your-pocket-id-domain.com
# Encryption key (choose one method):
# Method 1: Direct key (simple but less secure)
# Generate with: openssl rand -base64 32
ENCRYPTION_KEY=
# Method 2: File-based key (recommended)
# Put the base64 key in a file and point to it here.
# ENCRYPTION_KEY_FILE=/path/to/encryption_key
# These variables are optional but recommended to review:
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000

1
.github/CODEOWNERS vendored Normal file
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

@@ -2,11 +2,11 @@ name: Run Backend Linter
on:
push:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
pull_request:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
@@ -24,17 +24,17 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Set up Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
uses: golangci/golangci-lint-action@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

@@ -5,6 +5,10 @@ on:
branches:
- main
concurrency:
group: build-next-image
cancel-in-progress: true
jobs:
build-next:
runs-on: ubuntu-latest
@@ -15,17 +19,18 @@ jobs:
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
@@ -50,15 +55,14 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
run: pnpm install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: npm run build
run: pnpm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
run: sh scripts/development/build-binaries.sh --docker-only
- name: Build and push container image
id: build-push-image
@@ -68,11 +72,25 @@ jobs:
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: Dockerfile-prebuilt
file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: docker/Dockerfile-distroless
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true

View File

@@ -1,13 +1,13 @@
name: E2E Tests
on:
push:
branches: [main]
branches: [main, breaking/**]
paths-ignore:
- "docs/**"
- "**.md"
- ".github/**"
pull_request:
branches: [main]
branches: [main, breaking/**]
paths-ignore:
- "docs/**"
- "**.md"
@@ -22,7 +22,7 @@ jobs:
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
@@ -30,6 +30,8 @@ jobs:
- name: Build and export
uses: docker/build-push-action@v6
with:
context: .
file: docker/Dockerfile
push: false
load: false
tags: pocket-id:test
@@ -45,29 +47,107 @@ 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:
include:
- db: sqlite
storage: fs
- db: postgres
storage: fs
- db: sqlite
storage: s3
- db: sqlite
storage: database
- db: postgres
storage: database
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Cache Playwright Browsers
uses: actions/cache@v3
uses: actions/cache@v4
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
uses: actions/cache@v4
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
run: |
docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image
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@v4
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 lldap/lldap:2025-05-19
docker save lldap/lldap:2025-05-19 > /tmp/lldap-image.tar
- name: Load LLDAP image
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Cache Localstack S3 Docker image
if: matrix.storage == 's3'
uses: actions/cache@v4
id: s3-cache
with:
path: /tmp/localstack-s3-image.tar
key: localstack-s3-latest-${{ runner.os }}
- name: Pull and save Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit != 'true'
run: |
docker pull localstack/localstack:s3-latest
docker save localstack/localstack:s3-latest > /tmp/localstack-s3-image.tar
- name: Load Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/localstack-s3-image.tar
- name: Cache AWS CLI Docker image
if: matrix.storage == 's3'
uses: actions/cache@v4
id: aws-cli-cache
with:
path: /tmp/aws-cli-image.tar
key: aws-cli-latest-${{ runner.os }}
- name: Pull and save AWS CLI image
if: matrix.storage == 's3' && steps.aws-cli-cache.outputs.cache-hit != 'true'
run: |
docker pull amazon/aws-cli:latest
docker save amazon/aws-cli:latest > /tmp/aws-cli-image.tar
- name: Load AWS CLI image
if: matrix.storage == 's3' && steps.aws-cli-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/aws-cli-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
@@ -78,47 +158,51 @@ jobs:
- 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 test dependencies
working-directory: ./tests
run: npm ci
run: pnpm --filter pocket-id-tests install --frozen-lockfile
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB and LDAP
- name: Run Docker containers
working-directory: ./tests/setup
run: |
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
DOCKER_COMPOSE_FILE=docker-compose.yml
echo "FILE_BACKEND=${{ matrix.storage }}" > .env
if [ "${{ matrix.db }}" = "postgres" ]; then
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
elif [ "${{ matrix.storage }}" = "s3" ]; then
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
fi
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
{
LOG_FILE="/tmp/backend.log"
while true; do
CID=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q pocket-id)
if [ -n "$CID" ]; then
echo "[$(date)] Attaching logs for $CID" >> "$LOG_FILE"
docker logs -f --since=0 "$CID" >> "$LOG_FILE" 2>&1
else
echo "[$(date)] Container not yet running…" >> "$LOG_FILE"
fi
sleep 1
done
} &
- name: Run Playwright tests
working-directory: ./tests
run: npx playwright test
run: pnpm exec playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-sqlite
name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}
path: tests/.report
include-hidden-files: true
retention-days: 15
@@ -127,111 +211,7 @@ jobs:
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: 22
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-
- name: Cache PostgreSQL Docker image
uses: actions/cache@v3
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: 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'
run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image
uses: actions/cache@v3
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies
working-directory: ./tests
run: npm ci
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run Docker Container with Postgres DB and LDAP
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./tests
run: npx playwright test
- 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
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-postgres
name: backend-${{ matrix.db }}-${{ matrix.storage }}
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

View File

@@ -16,27 +16,25 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- uses: actions/setup-go@v5
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
@@ -53,17 +51,25 @@ jobs:
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
working-directory: frontend
run: npm ci
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend
working-directory: frontend
run: npm run build
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
@@ -73,20 +79,33 @@ jobs:
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
file: Dockerfile-prebuilt
file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless)
uses: docker/build-push-action@v6
id: container-build-push-distroless
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }}
file: docker/Dockerfile-distroless
- name: Binary attestation
uses: actions/attest-build-provenance@v2
with:
subject-path: "backend/.bin/pocket-id-**"
- name: Container image attestation
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true
- name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2
with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true
- name: Upload binaries to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -101,6 +120,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v3
uses: actions/checkout@v5
- name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -2,7 +2,7 @@ name: Svelte Check
on:
push:
branches: [main]
branches: [main, breaking/**]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"
@@ -34,26 +34,26 @@ jobs:
steps:
- name: Checkout code
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- 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

@@ -1,7 +1,7 @@
name: Unit Tests
on:
push:
branches: [main]
branches: [main, breaking/**]
paths:
- "backend/**"
pull_request:
@@ -16,8 +16,8 @@ jobs:
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: "backend/go.mod"
cache-dependency-path: "backend/go.sum"

View File

@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4
uses: actions/checkout@v5
- name: Fetch JSON data
run: |

5
.gitignore vendored
View File

@@ -1,8 +1,12 @@
# JetBrains
**/.idea
# Node
node_modules
# PNPM
.pnpm-store/
# Output
.output
.vercel
@@ -11,6 +15,7 @@ node_modules
/backend/bin
pocket-id
/tests/test-results/*.json
.tmp/
# OS
.DS_Store

View File

@@ -1 +1 @@
1.3.1
1.15.0

File diff suppressed because it is too large Load Diff

View File

@@ -28,7 +28,7 @@ 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
## Development Environment
@@ -52,7 +52,7 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
If you don't use Dev Containers, you need to install the following tools manually:
- [Node.js](https://nodejs.org/en/download/) >= 22
- [Go](https://golang.org/doc/install) >= 1.24
- [Go](https://golang.org/doc/install) >= 1.25
- [Git](https://git-scm.com/downloads)
### 2. Setup
@@ -69,10 +69,10 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
1. Open the `frontend` folder
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
3. Install the dependencies with `npm install`
4. Start the frontend with `npm run dev`
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`
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
@@ -84,11 +84,13 @@ If you are contributing to a new feature please ensure that you add tests for it
The tests can be run like this:
1. Visit the setup folder by running `cd tests/setup`
1. Install the dependencies from the root of the project `pnpm install`
2. Start the test environment by running `docker compose up -d --build`
2. Visit the setup folder by running `cd tests/setup`
3. Go back to the test folder by running `cd ..`
4. Run the tests with `npx playwright test`
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.

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

@@ -61,4 +61,4 @@ formatters:
paths:
- third_party$
- builtin$
- examples$
- examples$

View File

@@ -1,13 +1,10 @@
package main
import (
"flag"
"fmt"
"log"
"os"
_ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
@@ -17,27 +14,9 @@ import (
// @description.markdown
func main() {
// Get the command
// By default, this starts the server
var cmd string
flag.Parse()
args := flag.Args()
if len(args) > 0 {
cmd = args[0]
}
var err error
switch cmd {
case "version":
fmt.Println("pocket-id " + common.Version)
case "one-time-access-token":
err = cmds.OneTimeAccessToken(args)
default:
// Start the server
err = bootstrap.Bootstrap()
}
if err != nil {
log.Fatal(err.Error())
if err := common.ValidateEnvConfig(&common.EnvConfig); err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(1)
}
cmds.Execute()
}

View File

@@ -3,8 +3,10 @@
package frontend
import (
"bytes"
"embed"
"fmt"
"io"
"io/fs"
"net/http"
"os"
@@ -12,11 +14,44 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
)
//go:embed all:dist/*
var frontendFS embed.FS
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
var writeIndexFn func(w io.Writer, nonce string) error
func init() {
const scriptTag = "<script>"
// Read the index.html from the bundle
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
if iErr != nil {
panic(fmt.Errorf("failed to read index.html: %w", iErr))
}
writeIndexFn = func(w io.Writer, nonce string) (err error) {
// If there's no nonce, write the index as-is
if nonce == "" {
_, err = w.Write(index)
return err
}
// Add nonce to all <script> tags
// We replace "<script" with `<script nonce="..."` everywhere it appears
modified := bytes.ReplaceAll(
index,
[]byte(scriptTag),
[]byte(`<script nonce="`+nonce+`">`),
)
_, err = w.Write(modified)
return err
}
}
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
@@ -27,13 +62,40 @@ func RegisterFrontend(router *gin.Engine) error {
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 = "/"
if strings.HasSuffix(path, "/") {
c.Redirect(http.StatusMovedPermanently, strings.TrimRight(c.Request.URL.String(), "/"))
return
}
if strings.HasPrefix(path, "api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
}
// If path is / or does not exist, serve index.html
if path == "" {
path = "index.html"
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
path = "index.html"
}
if path == "index.html" {
nonce := middleware.GetCSPNonce(c)
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Status(http.StatusOK)
if err := writeIndexFn(c.Writer, nonce); err != nil {
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
}
return
}
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request)
})

View File

@@ -1,136 +1,174 @@
module github.com/pocket-id/pocket-id/backend
go 1.24.0
go 1.25
require (
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
github.com/aws/smithy-go v1.23.2
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/gin-contrib/slog v1.2.0
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.15.0
github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.25.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/go-co-op/gocron/v2 v2.18.1
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.15.0
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
github.com/lestrrat-go/jwx/v3 v3.0.1
github.com/lestrrat-go/httprc/v3 v3.0.1
github.com/lestrrat-go/jwx/v3 v3.0.12
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/stretchr/testify v1.10.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/sdk v1.35.0
go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0
golang.org/x/crypto v0.37.0
golang.org/x/image v0.24.0
golang.org/x/time v0.9.0
gorm.io/driver/postgres v1.5.11
gorm.io/gorm v1.25.12
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.1.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.45.0
golang.org/x/image v0.33.0
golang.org/x/sync v0.18.0
golang.org/x/text v0.31.0
golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // 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/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.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/disintegration/gift v1.2.1 // 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/glebarez/go-sqlite v1.21.2 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.2 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.11 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // 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-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.26 // 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/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // 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.7 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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.6 // 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.3 // indirect
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/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/mattn/go-sqlite3 v1.14.32 // 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/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
github.com/ncruces/go-strftime v1.0.0 // 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/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.0 // 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/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
go.opentelemetry.io/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-20250506013437-ce4c2cf36ca6 // indirect
golang.org/x/net v0.38.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.24.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
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.6 // indirect
modernc.org/libc v1.67.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.40.1 // indirect
)

View File

@@ -1,79 +1,123 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
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/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
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.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
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/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/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/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/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/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
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/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
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.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
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.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
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-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-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.18.1 h1:VVxgAghLW1Q6VHi/rc+B0ZSpFoUVlWgkw09Yximvn58=
github.com/go-co-op/gocron/v2 v2.18.1/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
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=
@@ -82,50 +126,62 @@ 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-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-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
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.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
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.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
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.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.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/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=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/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.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/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=
@@ -140,6 +196,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=
@@ -152,10 +210,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=
@@ -164,26 +220,32 @@ 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.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
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=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -197,240 +259,217 @@ 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/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/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.1.0 h1:2Iv7lmG9XtxuZA/jFAsd7LnZaC1E59pFsj5O/nU15pw=
github.com/oschwald/maxminddb-golang/v2 v2.1.0/go.mod h1:gG4V88LsawPEqtbL1Veh1WRh+nVSYwXzJ1P5Fcn77g0=
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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
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/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/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=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/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/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0/go.mod h1:fPl+qlrhRdRntIpPs9JoQ0iBKAsnH5VkgppU1f9kyF4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 h1:jj/B7eX95/mOxim9g9laNZkOHKz/XCHG0G410SntRy4=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/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/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=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0/go.mod h1:leO2CSTg0Y+LyvmR7Wm4pUxE8KAmaM2GCVx7O+RATLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 h1:0NIXxOCFx+SKbhCVxwl3ETG8ClLPAa0KuKV6p3yhxP8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0/go.mod h1:ChZSJbbfbl/DcRZNc9Gqh6DYGlfjw4PvO1pEOZH1ZsE=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk=
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk=
go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 h1:GKCEAZLEpEf78cUvudQdTg0aET2ObOZRB2HtXA0qPAI=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0/go.mod h1:9/zqSWLCmHT/9Jo6fYeUDRRogOLL60ABLsHWS99lF8s=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
go.opentelemetry.io/otel/log v0.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/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/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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 h1:/Rij/t18Y7rUayNg7Id6rPrEnHgorxYabm2E6wUdPP4=
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0/go.mod h1:AdyDPn6pkbkt2w01n3BubRVk7xAsCRq1Yg1mpfyA/0E=
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 h1:NLnZybb9KkfMXPwZhd5diBYJoVxiO9Qa06dacEA7ySY=
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0/go.mod h1:OvRg7gm5WRSCtxzGSsrFHbDLToYlStHNZQ+iPNIyD6g=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0 h1:5kSIJ0y8ckZZKoDhZHdVtcyjVi6rXyAwyaR8mp4zLbg=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0/go.mod h1:i+fIMHvcSQtsIY82/xgiVWRklrNt/O6QriHLjzGeY+s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo=
go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
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=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
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/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.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
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/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
golang.org/x/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
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=
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.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/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=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
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.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
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=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.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/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/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.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
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.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/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/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
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.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
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.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
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=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=

View File

@@ -0,0 +1,136 @@
package bootstrap
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
// initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory.
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
}
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("failed to read directory: %w", err)
}
destinationFiles, err := fileStorage.List(ctx, "application-images")
if err != nil {
if storage.IsNotExist(err) {
destinationFiles = []storage.ObjectInfo{}
} else {
return nil, fmt.Errorf("failed to list application images: %w", err)
}
}
dstNameToExt := make(map[string]string, len(destinationFiles))
for _, f := range destinationFiles {
// Skip directories
_, name := path.Split(f.Path)
if name == "" {
continue
}
nameWithoutExt, ext := utils.SplitFileName(name)
reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
continue
}
slog.Warn("Failed to open application image for hashing", slog.String("name", name), slog.Any("error", err))
continue
}
hash, err := hashStream(reader)
reader.Close()
if err != nil {
slog.Warn("Failed to hash application image", slog.String("name", name), slog.Any("error", err))
continue
}
// Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
if err := fileStorage.Delete(ctx, f.Path); err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
}
continue
}
dstNameToExt[nameWithoutExt] = ext
}
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() {
continue
}
name := sourceFile.Name()
nameWithoutExt, ext := utils.SplitFileName(name)
srcFilePath := path.Join("images", name)
if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
}
if err := fileStorage.Save(ctx, path.Join("application-images", name), srcFile); err != nil {
srcFile.Close()
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
}
srcFile.Close()
dstNameToExt[nameWithoutExt] = ext
}
return dstNameToExt, nil
}
type imageHashMap map[string][]byte
func (m imageHashMap) Contains(target []byte) bool {
if len(target) == 0 {
return false
}
for _, h := range m {
if bytes.Equal(h, target) {
return true
}
}
return false
}
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
panic(err)
}
return b
}
func hashStream(r io.Reader) ([]byte, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return nil, err
}
return h.Sum(nil), nil
}

View File

@@ -1,64 +0,0 @@
package bootstrap
import (
"log"
"os"
"path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources"
)
// initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() {
dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := resources.FS.ReadDir("images")
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
destinationFiles, err := os.ReadDir(dirPath)
if err != nil && !os.IsNotExist(err) {
log.Fatalf("Error reading directory: %v", err)
}
// Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
continue
}
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil {
log.Fatalf("Error copying file: %v", err)
}
}
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
for _, destinationFile := range destinationFiles {
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
if sourceFileWithoutExtension == destinationFileWithoutExtension {
return true
}
}
return false
}
func getImageNameWithoutExtension(fileName string) string {
idx := strings.LastIndexByte(fileName, '.')
if idx < 1 {
// No dot found, or fileName starts with a dot
return fileName
}
return fileName[:idx]
}

View File

@@ -3,38 +3,77 @@ package bootstrap
import (
"context"
"fmt"
"log"
"log/slog"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"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())
func Bootstrap(ctx context.Context) error {
var shutdownFns []utils.Service
defer func() { //nolint:contextcheck
// Invoke all shutdown functions on exit
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := utils.NewServiceRunner(shutdownFns...).Run(shutdownCtx); err != nil {
slog.Error("Error during graceful shutdown", "error", err)
}
}()
initApplicationImages()
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
// 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")
// Connect to the database
db := NewDatabase()
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
fileStorage, err := InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err)
}
imageExtensions, err := initApplicationImages(ctx, fileStorage)
if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient)
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
waitUntil, err := svc.appLockService.Acquire(ctx, false)
if err != nil {
return fmt.Errorf("failed to acquire application lock: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Until(waitUntil)):
}
shutdownFn := func(shutdownCtx context.Context) error {
sErr := svc.appLockService.Release(shutdownCtx)
if sErr != nil {
return fmt.Errorf("failed to release application lock: %w", sErr)
}
return nil
}
shutdownFns = append(shutdownFns, shutdownFn)
// Init the job scheduler
scheduler, err := job.NewScheduler()
if err != nil {
@@ -46,27 +85,51 @@ func Bootstrap() error {
}
// Init the router
router := initRouter(db, svc)
router, err := initRouter(db, svc)
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).
Run(ctx)
services := []utils.Service{svc.appLockService.RunRenewal, router}
if common.EnvConfig.AppEnv != "test" {
services = append(services, scheduler.Run)
}
err = utils.NewServiceRunner(services...).Run(ctx)
if err != nil {
return fmt.Errorf("failed to run services: %w", err)
}
// Invoke all shutdown functions
// We give these a timeout of 5s
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
err = utils.
NewServiceRunner(shutdownFns...).
Run(shutdownCtx)
if err != nil {
log.Printf("Error shutting down services: %v", err)
}
return nil
}
func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) {
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
case storage.TypeDatabase:
fileStorage, err = storage.NewDatabaseStorage(db)
case storage.TypeS3:
s3Cfg := storage.S3Config{
Bucket: common.EnvConfig.S3Bucket,
Region: common.EnvConfig.S3Region,
Endpoint: common.EnvConfig.S3Endpoint,
AccessKeyID: common.EnvConfig.S3AccessKeyID,
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
DisableDefaultIntegrityChecks: common.EnvConfig.S3DisableDefaultIntegrityChecks,
Root: common.EnvConfig.UploadPath,
}
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
default:
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
}
if err != nil {
return fileStorage, err
}
return fileStorage, nil
}

View File

@@ -1,97 +1,78 @@
package bootstrap
import (
"database/sql"
"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/iofs"
_ "github.com/golang-migrate/migrate/v4/source/github"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"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/resources"
"github.com/pocket-id/pocket-id/backend/internal/utils"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
)
func NewDatabase() (db *gorm.DB) {
db, err := connectDatabase()
func NewDatabase() (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)
}
// Choose the correct driver for the database provider
var driver database.Driver
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
log.Fatalf("failed to create migration driver: %v", err)
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Run migrations
if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err)
if err := utils.MigrateDatabase(sqlDb); err != nil {
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))
if err != nil {
return fmt.Errorf("failed to create embedded migration source: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %w", err)
}
err = m.Up()
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply migrations: %w", err)
}
return nil
}
func connectDatabase() (db *gorm.DB, err error) {
func ConnectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector
// Choose the correct database provider
var onConnFn func(conn *sql.DB)
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
}
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
sqliteutil.RegisterSqliteFunctions()
connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil {
return nil, err
}
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
err = ensureSqliteTempDir(filepath.Dir(dbPath))
if err != nil {
return nil, err
}
if isMemoryDB {
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
onConnFn = func(conn *sql.DB) {
conn.SetMaxOpenConns(1)
}
}
dialector = sqlite.Open(connString)
case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" {
@@ -105,38 +86,78 @@ 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)))
if onConnFn != nil {
conn, err := db.DB()
if err != nil {
slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
}
onConnFn(conn)
}
return db, nil
}
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 parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
// Check if we're using an in-memory database
isMemoryDB = isSqliteInMemory(connString)
// Parse the connection string
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", "", false, fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// Convert options for the old SQLite driver to the new one
convertSqlitePragmaArgs(connStringUrl)
// Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil {
return "", "", false, fmt.Errorf("invalid SQLite connection string: %w", err)
}
// Get the absolute path to the database
// Here, we know for a fact that the ? is present
parsedConnString = connStringUrl.String()
idx := strings.IndexRune(parsedConnString, '?')
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
if err != nil {
return "", "", false, fmt.Errorf("failed to determine absolute path to the database: %w", err)
}
return parsedConnString, dbPath, isMemoryDB, nil
}
// The official C implementation of SQLite allows some additional properties in the connection string
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
func parseSqliteConnectionString(connString string) (string, error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// 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 k {
switch strings.ToLower(k) {
case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout":
@@ -157,29 +178,182 @@ func parseSqliteConnectionString(connString string) (string, error) {
}
}
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
return connStringUrl.String(), nil
}
func getLogger() logger.Interface {
isProduction := common.EnvConfig.AppEnv == "production"
// 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
var logLevel logger.LogLevel
if isProduction {
logLevel = logger.Error
} else {
logLevel = logger.Info
// Get the "query string" from the connection string if present
qs := connStringUrl.Query()
if len(qs) == 0 {
qs = make(url.Values, 2)
}
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 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.LogLevel == "debug" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(),
)
} else {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
}
return slogGorm.New(loggerOpts...)
}

View File

@@ -8,23 +8,93 @@ import (
"github.com/stretchr/testify/require"
)
func TestParseSqliteConnectionString(t *testing.T) {
func TestIsSqliteInMemory(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectedError bool
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: "adds file: prefix if missing",
input: "test.db",
expected: "file:test.db",
},
{
name: "converts _busy_timeout to pragma",
input: "file:test.db?_busy_timeout=5000",
@@ -100,46 +170,155 @@ func TestParseSqliteConnectionString(t *testing.T) {
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: "invalid URL format",
input: "file:invalid#$%^&*@test.db",
expectedError: true,
name: "basic file database",
input: "file:test.db",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "in-memory database",
input: "file::memory:",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate",
},
{
name: "read-only database with mode=ro",
input: "file:test.db?mode=ro",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "immutable database",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "database with existing _txlock",
input: "file:test.db?_txlock=deferred",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
},
{
name: "database with existing busy_timeout pragma",
input: "file:test.db?_pragma=busy_timeout%285000%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "database with existing journal_mode pragma",
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
},
{
name: "database with forbidden foreign_keys pragma",
input: "file:test.db?_pragma=foreign_keys%280%29",
isMemoryDB: false,
expectError: true,
},
{
name: "database with multiple existing pragmas",
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
},
{
name: "database with mode=rw (not read-only)",
input: "file:test.db?mode=rw",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
},
{
name: "database with immutable=0 (not immutable)",
input: "file:test.db?immutable=0",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
},
{
name: "database with mixed case mode=RO",
input: "file:test.db?mode=RO",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "database with mixed case immutable=1",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "complex database configuration",
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseSqliteConnectionString(tt.input)
resultURL, err := url.Parse(tt.input)
require.NoError(t, err)
if tt.expectedError {
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
if tt.expectError {
require.Error(t, err)
return
}
require.NoError(t, err)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
resultURL, err := url.Parse(result)
require.NoError(t, err)
// 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)
}
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,7 +3,8 @@
package bootstrap
import (
"log"
"log/slog"
"os"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
@@ -16,9 +17,10 @@ import (
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
if err != nil {
log.Fatalf("failed to initialize test service: %v", err)
slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)
return
}

View File

@@ -0,0 +1,203 @@
package bootstrap
import (
"context"
"fmt"
"log/slog"
"net/http"
"os"
"time"
sloggin "github.com/gin-contrib/slog"
"github.com/lmittmann/tint"
"github.com/mattn/go-isatty"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
globallog "go.opentelemetry.io/otel/log/global"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
sdklog "go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName(common.Name),
semconv.ServiceVersion(common.Version),
),
)
}
func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
resource, err := defaultResource()
if err != nil {
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
}
shutdownFns = make([]utils.Service, 0, 2)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
httpClient.Transport = defaultTransport.Clone()
// Logging
err = initOtelLogging(ctx, resource)
if err != nil {
return nil, nil, err
}
// Tracing
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
if err != nil {
return nil, nil, err
} else if tracingShutdownFn != nil {
shutdownFns = append(shutdownFns, tracingShutdownFn)
}
// Metrics
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, resource)
if err != nil {
return nil, nil, err
} else if metricsShutdownFn != nil {
shutdownFns = append(shutdownFns, metricsShutdownFn)
}
return shutdownFns, httpClient, nil
}
func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
os.Setenv("OTEL_LOGS_EXPORTER", "none")
}
exp, err := autoexport.NewLogExporter(ctx)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
}
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
// Create the handler
var handler slog.Handler
if common.EnvConfig.LogJSON {
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
})
} else {
handler = tint.NewHandler(os.Stdout, &tint.Options{
TimeFormat: time.Stamp,
Level: level,
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
})
}
// Create the logger provider
provider := sdklog.NewLoggerProvider(
sdklog.WithProcessor(
sdklog.NewBatchProcessor(exp),
),
sdklog.WithResource(resource),
)
// Set the logger provider globally
globallog.SetLoggerProvider(provider)
// Wrap the handler in a "fanout" one
handler = utils.LogFanoutHandler{
handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
}
// Set the default slog to send logs to OTel and add the app name
log := slog.New(handler).
With(slog.String("app", common.Name)).
With(slog.String("version", common.Version))
slog.SetDefault(log)
return nil
}
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
if !traces {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
return nil, nil
}
tr, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource),
sdktrace.WithBatcher(tr),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer tpCancel()
shutdownErr := tp.Shutdown(tpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
}
return nil
}
// Add tracing to the HTTP client
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
return shutdownFn, nil
}
func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
if !metrics {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
return nil, nil
}
mr, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer mpCancel()
shutdownErr := mp.Shutdown(mpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
}
return nil
}
return shutdownFn, nil
}

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

@@ -4,18 +4,21 @@ import (
"context"
"errors"
"fmt"
"log"
"log/slog"
"net"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/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,45 +29,38 @@ import (
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc)
if err != nil {
log.Fatalf("failed to init router: %v", err)
}
return runner
}
func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case "production":
case common.AppEnvProduction:
gin.SetMode(gin.ReleaseMode)
case "development":
case common.AppEnvDevelopment:
gin.SetMode(gin.DebugMode)
case "test":
case common.AppEnvTest:
gin.SetMode(gin.TestMode)
}
r := gin.Default()
r.Use(gin.Logger())
r := gin.New()
initLogger(r)
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)
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
log.Println("Frontend is not included in the build. Skipping frontend registration.")
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)
}
@@ -80,12 +76,14 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.appConfigService)
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
if !common.EnvConfig.AppEnv.IsProduction() {
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
@@ -112,16 +110,29 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
}
listener, err := net.Listen(network, addr)
listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil {
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", addr)
slog.Info("Server listening", slog.String("addr", addr))
// Start the server in a background goroutine
go func() {
@@ -130,7 +141,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)
}
}()
@@ -138,7 +150,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
@@ -151,7 +163,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
@@ -159,3 +171,29 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
return runFn, nil
}
func initLogger(r *gin.Engine) {
loggerSkipPathsPrefix := []string{
"GET /api/application-images/logo",
"GET /api/application-images/background",
"GET /api/application-images/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r.Use(sloggin.SetLogger(
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
return slog.Default()
}),
sloggin.WithSkipper(func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return true
}
}
return false
}),
))
}

View File

@@ -23,7 +23,7 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http
if err != nil {
return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterFileCleanupJobs(ctx, db)
err = scheduler.RegisterFileCleanupJobs(ctx, db, svc.fileStorage)
if err != nil {
return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err)
}

View File

@@ -8,10 +8,12 @@ import (
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
type services struct {
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
@@ -23,13 +25,23 @@ type services struct {
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
}
// Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(ctx, db)
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
if err != nil {
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
svc.fileStorage = fileStorage
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
svc.appLockService = service.NewAppLockService(db)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
@@ -38,19 +50,28 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
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.customClaimService = service.NewCustomClaimService(db)
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient, fileStorage)
if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
}
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
svc.versionService = service.NewVersionService(httpClient)
return svc, nil
}

View File

@@ -0,0 +1,70 @@
package cmds
import (
"context"
"fmt"
"io"
"os"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/spf13/cobra"
)
type exportFlags struct {
Path string
}
func init() {
var flags exportFlags
exportCmd := &cobra.Command{
Use: "export",
Short: "Exports all data of Pocket ID into a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runExport(cmd.Context(), flags)
},
}
exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout")
rootCmd.AddCommand(exportCmd)
}
// runExport orchestrates the export flow
func runExport(ctx context.Context, flags exportFlags) error {
db, err := bootstrap.NewDatabase()
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
storage, err := bootstrap.InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
exportService := service.NewExportService(db, storage)
var w io.Writer
if flags.Path == "-" {
w = os.Stdout
} else {
file, err := os.Create(flags.Path)
if err != nil {
return fmt.Errorf("failed to create export file: %w", err)
}
defer file.Close()
w = file
}
if err := exportService.ExportToZip(ctx, w); err != nil {
return fmt.Errorf("failed to export data: %w", err)
}
if flags.Path != "-" {
fmt.Printf("Exported data to %s\n", flags.Path)
}
return nil
}

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,191 @@
package cmds
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"time"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type importFlags struct {
Path string
Yes bool
ForcefullyAcquireLock bool
}
func init() {
var flags importFlags
importCmd := &cobra.Command{
Use: "import",
Short: "Imports all data of Pocket ID from a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runImport(cmd.Context(), flags)
},
}
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance")
rootCmd.AddCommand(importCmd)
}
// runImport handles the high-level orchestration of the import process
func runImport(ctx context.Context, flags importFlags) error {
if !flags.Yes {
ok, err := askForConfirmation()
if err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
var (
zipReader *zip.ReadCloser
cleanup func()
err error
)
if flags.Path == "-" {
zipReader, cleanup, err = readZipFromStdin()
defer cleanup()
} else {
zipReader, err = zip.OpenReader(flags.Path)
}
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer zipReader.Close()
db, err := bootstrap.ConnectDatabase()
if err != nil {
return err
}
err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock)
if err != nil {
return err
}
storage, err := bootstrap.InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
importService := service.NewImportService(db, storage)
err = importService.ImportFromZip(ctx, &zipReader.Reader)
if err != nil {
return fmt.Errorf("failed to import data from zip: %w", err)
}
fmt.Println("Import completed successfully.")
return nil
}
func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
// Check if the kv table exists, in case we are starting from an empty database
exists, err := utils.DBTableExists(db, "kv")
if err != nil {
return fmt.Errorf("failed to check if kv table exists: %w", err)
}
if !exists {
// This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks
// In either case, there's no lock to acquire
fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.")
return nil
}
// Note that we do not call a deferred Release if the data was imported
// This is because we are overriding the contents of the database, so the lock is automatically lost
appLockService := service.NewAppLockService(db)
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
waitUntil, err := appLockService.Acquire(opCtx, force)
if err != nil {
if errors.Is(err, service.ErrLockUnavailable) {
//nolint:staticcheck
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
}
return fmt.Errorf("failed to acquire application lock: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Until(waitUntil)):
}
return nil
}
func askForConfirmation() (bool, error) {
fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.")
fmt.Println()
fmt.Println("WARNING: Import will erase all existing data at the following locations:")
fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString))
fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath))
ok, err := utils.PromptForConfirmation("Do you want to continue?")
if err != nil {
return false, err
}
return ok, nil
}
// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails
func absolutePathOrOriginal(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
return abs
}
func readZipFromStdin() (*zip.ReadCloser, func(), error) {
tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip")
if err != nil {
return nil, nil, fmt.Errorf("failed to create temporary file: %w", err)
}
cleanup := func() {
_ = os.Remove(tmpFile.Name())
}
if _, err := io.Copy(tmpFile, os.Stdin); err != nil {
tmpFile.Close()
cleanup()
return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err)
}
if err := tmpFile.Close(); err != nil {
cleanup()
return nil, nil, fmt.Errorf("failed to close temporary file: %w", err)
}
r, err := zip.OpenReader(tmpFile.Name())
if err != nil {
cleanup()
return nil, nil, err
}
return r, cleanup, nil
}

View File

@@ -0,0 +1,114 @@
package cmds
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/spf13/cobra"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/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")
os.Exit(1)
}
}
// 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,150 @@
package cmds
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/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) {
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
})
}
}
func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
// Set up database storage config
envConfig := &common.EnvConfigSchema{
EncryptionKey: []byte("test-encryption-key-characters-long"),
}
// Create test database
db := testingutils.NewDatabaseForTest(t)
// 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

@@ -6,77 +6,80 @@ import (
"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"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
// OneTimeAccessToken creates a one-time access token for the given user
// Args must contain the username or email of the user
func OneTimeAccessToken(args []string) error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
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]
// Get the username or email of the user
// Note length is 2 because the first argument is always the command (one-time-access-token)
if len(args) != 2 {
return errors.New("missing username or email of user; usage: one-time-access-token <username or email>")
}
userArg := args[1]
// Connect to the database
db := bootstrap.NewDatabase()
// 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(ctx, 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")
// Connect to the database
db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
// 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
}
queryCtx, queryCancel = context.WithTimeout(ctx, 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)
}
// 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
})
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,37 @@
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.",
SilenceUsage: true,
Run: func(cmd *cobra.Command, args []string) {
// Start the server
err := bootstrap.Bootstrap(cmd.Context())
if err != nil {
slog.Error("Failed to run pocket-id", "error", err)
os.Exit(1)
}
},
}
func Execute() {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
err := rootCmd.ExecuteContext(ctx)
if err != nil {
os.Exit(1)
}
}

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,13 +1,21 @@
package common
import (
"log"
"errors"
"fmt"
"log/slog"
"net"
"net/url"
"os"
"reflect"
"strings"
"github.com/caarlos0/env/v11"
sloggin "github.com/gin-contrib/slog"
_ "github.com/joho/godotenv/autoload"
)
type AppEnv string
type DbProvider string
const (
@@ -18,75 +26,255 @@ 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"
AppEnvProduction AppEnv = "production"
AppEnvDevelopment AppEnv = "development"
AppEnvTest AppEnv = "test"
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "data/pocket-id.db"
defaultFsUploadPath string = "data/uploads"
AppUrl string = "http://localhost:1411"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"`
Port string `env:"PORT"`
Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
LogJSON bool `env:"LOG_JSON"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate",
UploadPath: "data/uploads",
KeysPath: "data/keys",
AppURL: "http://localhost:1411",
Port: "1411",
Host: "0.0.0.0",
UnixSocket: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
var EnvConfig = defaultConfig()
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
}
// Validate the environment variables
switch EnvConfig.DbProvider {
case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" {
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
err := parseEnvConfig()
if err != nil {
log.Fatal("APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
log.Fatal("APP_URL must not contain a path")
slog.Error("Configuration error", slog.Any("error", err))
os.Exit(1)
}
}
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "fs",
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
}
func parseEnvConfig() error {
parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
return []byte(value), nil
},
}
err := env.ParseWithOptions(&EnvConfig, env.Options{
FuncMap: parsers,
})
if err != nil {
return fmt.Errorf("error parsing env config: %w", err)
}
err = prepareEnvConfig(&EnvConfig)
if err != nil {
return fmt.Errorf("error preparing env config: %w", err)
}
return nil
}
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
if len(config.EncryptionKey) < 16 {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
switch {
case config.DbConnectionString == "":
config.DbProvider = DbProviderSqlite
config.DbConnectionString = defaultSqliteConnString
case strings.HasPrefix(config.DbConnectionString, "postgres://") || strings.HasPrefix(config.DbConnectionString, "postgresql://"):
config.DbProvider = DbProviderPostgres
default:
config.DbProvider = DbProviderSqlite
}
parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil {
return errors.New("APP_URL is not a valid URL")
}
if parsedAppUrl.Path != "" {
return errors.New("APP_URL must not contain a path")
}
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
} else {
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL")
}
if parsedInternalAppUrl.Path != "" {
return errors.New("INTERNAL_APP_URL must not contain a path")
}
}
switch config.FileBackend {
case "s3", "database":
case "", "fs":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
}
default:
return errors.New("invalid FILE_BACKEND value. Must be 'fs', 'database', or 's3'")
}
// Validate LOCAL_IPV6_RANGES
ranges := strings.Split(config.LocalIPv6Ranges, ",")
for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
}
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
}
return nil
}
// prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options")
options := strings.Split(optionsTag, ",")
for _, option := range options {
switch option {
case "toLower":
if field.Kind() == reflect.String {
field.SetString(strings.ToLower(field.String()))
}
case "file":
err := resolveFileBasedEnvVariable(field, fieldType)
if err != nil {
return err
}
case "trimTrailingSlash":
if field.Kind() == reflect.String {
field.SetString(strings.TrimRight(field.String(), "/"))
}
}
}
}
return nil
}
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return nil
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return nil
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
return nil
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
return nil
}
func (a AppEnv) IsProduction() bool {
return a == AppEnvProduction
}
func (a AppEnv) IsTest() bool {
return a == AppEnvTest
}

View File

@@ -0,0 +1,222 @@
package common
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func parseAndValidateEnvConfig(t *testing.T) error {
t.Helper()
if _, exists := os.LookupEnv("ENCRYPTION_KEY"); !exists {
t.Setenv("ENCRYPTION_KEY", "0123456789abcdef")
}
if err := parseEnvConfig(); err != nil {
return err
}
return ValidateEnvConfig(&EnvConfig)
}
func TestParseEnvConfig(t *testing.T) {
// Store original config to restore later
originalConfig := EnvConfig
t.Cleanup(func() {
EnvConfig = originalConfig
})
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
})
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
})
t.Run("should fail when ENCRYPTION_KEY is too short", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("ENCRYPTION_KEY", "short")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be at least 16 bytes long")
})
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("APP_URL", "http://localhost:3000")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
})
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "€://not-a-valid-url")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL is not a valid URL")
})
t.Run("should fail when APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000/path")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL must not contain a path")
})
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
})
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
})
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("UI_CONFIG_DISABLED", "true")
t.Setenv("METRICS_ENABLED", "true")
t.Setenv("TRACING_ENABLED", "false")
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
err := parseAndValidateEnvConfig(t)
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_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "PRODUCTION")
t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, AppEnvProduction, EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
})
t.Run("should normalize file backend and default upload path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "FS")
t.Setenv("UPLOAD_PATH", "")
err := parseAndValidateEnvConfig(t)
require.NoError(t, err)
assert.Equal(t, "fs", EnvConfig.FileBackend)
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
})
t.Run("should fail with invalid FILE_BACKEND value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "invalid")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
})
}
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create test files
encryptionKeyFile := tempDir + "/encryption_key.txt"
encryptionKeyContent := "test-encryption-key-123"
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb"
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err)
t.Run("should process toLower and file options", func(t *testing.T) {
config := defaultConfig()
config.AppEnv = "STAGING"
config.Host = "LOCALHOST"
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := prepareEnvConfig(&config)
require.NoError(t, err)
assert.Equal(t, AppEnv("staging"), config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig()
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := prepareEnvConfig(&config)
require.NoError(t, err)
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
}

View File

@@ -349,3 +349,52 @@ 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
}
type UserEmailNotSetError struct{}
func (e *UserEmailNotSetError) Error() string {
return "The user does not have an email address set"
}
func (e *UserEmailNotSetError) HttpStatusCode() int {
return http.StatusBadRequest
}
type ImageNotFoundError struct{}
func (e *ImageNotFoundError) Error() string {
return "Image not found"
}
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}

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

@@ -45,15 +45,11 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(ctx)
userID := ctx.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := ctx.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = ctx.Error(err)
return
}
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, sortedPaginationRequest)
apiKeys, pagination, err := c.apiKeyService.ListApiKeys(ctx.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = ctx.Error(err)
return
@@ -82,7 +78,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

@@ -9,7 +9,6 @@ import (
"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"
)
// NewAppConfigController creates a new controller for application configuration endpoints
@@ -33,13 +32,6 @@ func NewAppConfigController(
group.GET("/application-configuration/all", authMiddleware.Add(), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", authMiddleware.Add(), acc.updateAppConfigHandler)
group.GET("/application-configuration/logo", acc.getLogoHandler)
group.GET("/application-configuration/background-image", acc.getBackgroundImageHandler)
group.GET("/application-configuration/favicon", acc.getFaviconHandler)
group.PUT("/application-configuration/logo", authMiddleware.Add(), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", authMiddleware.Add(), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", authMiddleware.Add(), acc.updateBackgroundImageHandler)
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
}
@@ -108,7 +100,7 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @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
}
@@ -128,145 +120,6 @@ func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
c.JSON(http.StatusOK, configVariablesDto)
}
// getLogoHandler godoc
// @Summary Get logo image
// @Description Get the logo image for the application
// @Tags Application Configuration
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-configuration/logo [get]
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
dbConfig := acc.appConfigService.GetDbConfig()
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName, imageType string
if lightLogo {
imageName = "logoLight"
imageType = dbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = dbConfig.LogoDarkImageType.Value
}
acc.getImage(c, imageName, imageType)
}
// getFaviconHandler godoc
// @Summary Get favicon
// @Description Get the favicon for the application
// @Tags Application Configuration
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Router /api/application-configuration/favicon [get]
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
acc.getImage(c, "favicon", "ico")
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
// @Tags Application Configuration
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Router /api/application-configuration/background-image [get]
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
acc.getImage(c, "background", imageType)
}
// updateLogoHandler godoc
// @Summary Update logo
// @Description Update the application logo
// @Tags Application Configuration
// @Accept multipart/form-data
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Router /api/application-configuration/logo [put]
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
dbConfig := acc.appConfigService.GetDbConfig()
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName, imageType string
if lightLogo {
imageName = "logoLight"
imageType = dbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = dbConfig.LogoDarkImageType.Value
}
acc.updateImage(c, imageName, imageType)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Configuration
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content"
// @Router /api/application-configuration/favicon [put]
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
_ = c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
_ = c.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
acc.updateImage(c, "favicon", "ico")
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image
// @Tags Application Configuration
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Router /api/application-configuration/background-image [put]
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
acc.updateImage(c, "background", imageType)
}
// getImage is a helper function to serve image files
func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType string) {
imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
// updateImage is a helper function to update image files
func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, oldImageType string) {
file, err := c.FormFile("file")
if err != nil {
_ = c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// syncLdapHandler godoc
// @Summary Synchronize LDAP
// @Description Manually trigger LDAP synchronization

View File

@@ -0,0 +1,228 @@
package controller
import (
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func NewAppImagesController(
group *gin.RouterGroup,
authMiddleware *middleware.AuthMiddleware,
appImagesService *service.AppImagesService,
) {
controller := &AppImagesController{
appImagesService: appImagesService,
}
group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
}
type AppImagesController struct {
appImagesService *service.AppImagesService
}
// getLogoHandler godoc
// @Summary Get logo image
// @Description Get the logo image for the application
// @Tags Application Images
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-images/logo [get]
func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
c.getImage(ctx, imageName)
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Background image"
// @Router /api/application-images/background [get]
func (c *AppImagesController) getBackgroundImageHandler(ctx *gin.Context) {
c.getImage(ctx, "background")
}
// getFaviconHandler godoc
// @Summary Get favicon
// @Description Get the favicon for the application
// @Tags Application Images
// @Produce image/x-icon
// @Success 200 {file} binary "Favicon image"
// @Router /api/application-images/favicon [get]
func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
c.getImage(ctx, "favicon")
}
// getDefaultProfilePicture godoc
// @Summary Get default profile picture image
// @Description Get the default profile picture image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Default profile picture image"
// @Router /api/application-images/default-profile-picture [get]
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
c.getImage(ctx, "default-profile-picture")
}
// updateLogoHandler godoc
// @Summary Update logo
// @Description Update the application logo
// @Tags Application Images
// @Accept multipart/form-data
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Param file formData file true "Logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/logo [put]
func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
lightLogo, _ := strconv.ParseBool(ctx.DefaultQuery("light", "true"))
imageName := "logoLight"
if !lightLogo {
imageName = "logoDark"
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Background image file"
// @Success 204 "No Content"
// @Router /api/application-images/background [put]
func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateFaviconHandler godoc
// @Summary Update favicon
// @Description Update the application favicon
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content"
// @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
return
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
if err != nil {
_ = ctx.Error(err)
return
}
defer reader.Close()
ctx.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
}
// updateDefaultProfilePicture godoc
// @Summary Update default profile picture image
// @Description Update the default profile picture image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Profile picture image file"
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [put]
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// deleteDefaultProfilePicture godoc
// @Summary Delete default profile picture image
// @Description Delete the default profile picture image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [delete]
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -41,18 +41,12 @@ type AuditLogController struct {
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
err := c.ShouldBindQuery(&sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -86,25 +80,12 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @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"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
var filters dto.AuditLogFilterDto
if err := c.ShouldBindQuery(&filters); err != nil {
_ = c.Error(err)
return
}
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err != nil {
_ = c.Error(err)
return

View File

@@ -59,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,7 +93,7 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
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

@@ -33,22 +33,30 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
}
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.ResetLock(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if !skipSeed {
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
}
}
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
@@ -66,8 +74,6 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
}
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}

View File

@@ -2,10 +2,12 @@ package controller
import (
"errors"
"log"
"log/slog"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -54,8 +56,13 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
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/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
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 {
@@ -88,6 +95,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
response := dto.AuthorizeOidcClientResponseDto{
Code: code,
CallbackURL: callbackURL,
Issuer: common.EnvConfig.AppURL,
}
c.JSON(http.StatusOK, response)
@@ -255,7 +263,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
}
@@ -350,6 +358,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto)
return
}
@@ -396,13 +405,9 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -416,6 +421,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
_ = c.Error(err)
return
}
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
@@ -485,11 +491,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"
// @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
@@ -536,17 +542,23 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
// @Produce image/jpeg
// @Produce image/svg+xml
// @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 200 {file} binary "Logo image"
// @Router /api/oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"))
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
if err != nil {
_ = c.Error(err)
return
}
defer reader.Close()
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType)
c.File(imagePath)
c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
}
// updateClientLogoHandler godoc
@@ -556,6 +568,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Accept multipart/form-data
// @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
@@ -565,7 +578,9 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
return
}
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file)
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
if err != nil {
_ = c.Error(err)
return
@@ -579,10 +594,19 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Description Delete the logo for an OIDC client
// @Tags OIDC
// @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
var err error
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
if lightLogo {
err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
} else {
err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id"))
}
if err != nil {
_ = c.Error(err)
return
@@ -619,6 +643,7 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
_ = c.Error(err)
return
}
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
c.JSON(http.StatusOK, oidcClientDto)
}
@@ -653,7 +678,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// @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/clients [get]
// @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
@@ -669,19 +694,16 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
// @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}/clients [get]
// @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)
listRequestOptions := utils.ParseListRequestOptions(c)
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -700,6 +722,54 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
})
}
// 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) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID")
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
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 == "" {
@@ -767,7 +837,7 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
if err != nil {
_ = c.Error(err)
return

View File

@@ -14,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
@@ -44,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 {
@@ -93,13 +104,9 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -187,7 +194,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
}
@@ -250,10 +257,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)
}
@@ -282,7 +286,7 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err)
return
}
@@ -313,7 +317,7 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err)
return
}
@@ -328,10 +332,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
@@ -375,7 +386,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
}
@@ -408,7 +419,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
@@ -443,14 +458,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
@@ -498,10 +522,134 @@ 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) {
listRequestOptions := utils.ParseListRequestOptions(c)
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
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
}
@@ -539,7 +687,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}
@@ -557,7 +705,7 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err)
return
}

View File

@@ -47,16 +47,10 @@ type UserGroupController struct {
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
ctx := c.Request.Context()
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
listRequestOptions := utils.ParseListRequestOptions(c)
groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
groups, pagination, err := ugc.UserGroupService.List(c, searchTerm, listRequestOptions)
if err != nil {
_ = c.Error(err)
return
@@ -70,7 +64,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
_ = c.Error(err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(c.Request.Context(), group.ID)
if err != nil {
_ = c.Error(err)
return
@@ -120,7 +114,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @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
}
@@ -152,7 +146,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @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
}

View File

@@ -0,0 +1,40 @@
package controller
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
}
type VersionController struct {
versionService *service.VersionService
}
// getLatestVersionHandler godoc
// @Summary Get latest available version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Latest version information"
// @Router /api/version/latest [get]
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
c.JSON(http.StatusOK, gin.H{
"latestVersion": tag,
})
}

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)
@@ -55,7 +57,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request)
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
if err != nil {
_ = c.Error(err)
return
@@ -132,8 +134,10 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID)
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent)
if err != nil {
_ = c.Error(err)
return
@@ -171,3 +175,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)
@@ -64,25 +67,30 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL
internalAppUrl := common.EnvConfig.InternalAppURL
alg, err := wkc.jwtService.GetKeyAlg()
if err != nil {
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{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()},
"issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize",
"token_endpoint": internalAppUrl + "/api/oidc/token",
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"},
"subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
}
return json.Marshal(config)
}

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,16 @@ 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"`
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
@@ -37,11 +42,12 @@ type AppConfigUpdateDto struct {
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
LdapAdminGroupName string `json:"ldapAdminGroupName"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`

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,12 @@ 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"`
}
type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"`
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"`
}

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,162 +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
}
//nolint:gocognit
func mapField(sourceField reflect.Value, destField reflect.Value) error {
// Handle pointer to struct in source
if sourceField.Kind() == reflect.Ptr && !sourceField.IsNil() {
switch {
case sourceField.Elem().Kind() == reflect.Struct:
switch {
case destField.Kind() == reflect.Struct:
// Map from pointer to struct -> struct
return mapStructInternal(sourceField.Elem(), destField)
case destField.Kind() == reflect.Ptr && destField.CanSet():
// Map from pointer to struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField.Elem(), destField.Elem())
}
case destField.Kind() == reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type().Elem()):
// Handle primitive pointer types (e.g., *string to *string)
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField.Elem())
return nil
case destField.Kind() != reflect.Ptr &&
destField.CanSet() &&
sourceField.Elem().Type().AssignableTo(destField.Type()):
// Handle *T to T conversion for primitive types
destField.Set(sourceField.Elem())
return nil
}
}
// Handle pointer to struct in destination
if destField.Kind() == reflect.Ptr && destField.CanSet() {
switch {
case sourceField.Kind() == reflect.Struct:
// Map from struct -> pointer to struct
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
return mapStructInternal(sourceField, destField.Elem())
case !sourceField.IsZero() && sourceField.Type().AssignableTo(destField.Type().Elem()):
// Handle T to *T conversion for primitive types
if destField.IsNil() {
destField.Set(reflect.New(destField.Type().Elem()))
}
destField.Elem().Set(sourceField)
return nil
}
}
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,9 +1,14 @@
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"`
HasDarkLogo bool `json:"hasDarkLogo"`
LaunchURL *string `json:"launchURL"`
RequiresReauthentication bool `json:"requiresReauthentication"`
}
type OidcClientDto struct {
@@ -25,13 +30,24 @@ type OidcClientWithAllowedGroupsCountDto struct {
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
}
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
OidcClientUpdateDto
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
}
type OidcClientCredentialsDto struct {
@@ -46,17 +62,19 @@ type OidcClientFederatedIdentityDto struct {
}
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 {
@@ -74,6 +92,7 @@ type OidcCreateTokensDto struct {
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
Resource string `form:"resource"`
}
type OidcIntrospectDto struct {
@@ -144,12 +163,18 @@ type DeviceCodeInfoDto struct {
}
type AuthorizedOidcClientDto struct {
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
}
type OidcClientPreviewDto struct {
IdToken map[string]interface{} `json:"idToken"`
AccessToken map[string]interface{} `json:"accessToken"`
UserInfo map[string]interface{} `json:"userInfo"`
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,13 +1,19 @@
package dto
import "time"
import (
"errors"
"github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email" `
Email *string `json:"email" `
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
@@ -17,30 +23,50 @@ 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"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(u)
}
type OneTimeAccessTokenCreateDto struct {
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:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"`
}

View File

@@ -0,0 +1,105 @@
package dto
import (
"testing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require"
)
func TestUserCreateDto_Validate(t *testing.T) {
testCases := []struct {
name string
input UserCreateDto
wantErr string
}{
{
name: "valid input",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "",
},
{
name: "missing username",
input: UserCreateDto{
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'required' tag",
},
{
name: "missing display name",
input: UserCreateDto{
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
},
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
},
{
name: "username contains invalid characters",
input: UserCreateDto{
Username: "test/ser",
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'username' tag",
},
{
name: "invalid email",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("not-an-email"),
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Email' failed on the 'email' tag",
},
{
name: "first name too short",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("test@example.com"),
FirstName: "",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
},
{
name: "last name too long",
input: UserCreateDto{
Username: "testuser",
Email: utils.Ptr("test@example.com"),
FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.input.Validate()
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.ErrorContains(t, err, tc.wantErr)
})
}
}

View File

@@ -1,6 +1,9 @@
package dto
import (
"errors"
"github.com/gin-gonic/gin/binding"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -34,11 +37,22 @@ 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:"-"`
}
func (g UserGroupCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(g)
}
type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"`
}

View File

@@ -1,26 +1,85 @@
package dto
import (
"log"
"net/url"
"regexp"
"strings"
"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 {
// [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
}
// [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
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateClientIDRegex = regexp.MustCompile("^[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)
v := binding.Validator.Engine().(*validator.Validate)
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return ValidateUsername(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for username: " + err.Error())
}
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}); err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for callback_url: " + err.Error())
}
}
// ValidateUsername validates username inputs
func ValidateUsername(username string) bool {
return validateUsernameRegex.MatchString(username)
}
// ValidateClientID validates client ID inputs
func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool {
// Don't validate if it contains a wildcard
if strings.Contains(raw, "*") {
return true
}
u, err := url.Parse(raw)
if err != nil {
return false
}
if !u.IsAbs() {
return false
}
return true
}

View File

@@ -0,0 +1,58 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "user123", true},
{"valid with dot", "user.name", true},
{"valid with underscore", "user_name", true},
{"valid with hyphen", "user-name", true},
{"valid with at", "user@name", true},
{"starts with symbol", ".username", false},
{"ends with non-alphanumeric", "username-", false},
{"contains space", "user name", false},
{"empty", "", false},
{"only special chars", "-._@", false},
{"valid long", "a1234567890_b.c-d@e", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateUsername(tt.input))
})
}
}
func TestValidateClientID(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "client123", true},
{"valid with dot", "client.id", true},
{"valid with underscore", "client_id", true},
{"valid with hyphen", "client-id", true},
{"valid with all", "client.id-123_abc", true},
{"contains space", "client id", false},
{"contains at", "client@id", false},
{"empty", "", false},
{"only special chars", "-._", true},
{"invalid char", "client!id", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateClientID(tt.input))
})
}
}

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

@@ -19,7 +19,7 @@ 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" {
if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() {
return nil
}
@@ -39,7 +39,7 @@ type AnalyticsJob struct {
// 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" {
if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() {
return nil
}

View File

@@ -37,7 +37,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
}
for _, key := range apiKeys {
if key.User.Email == "" {
if key.User.Email == nil {
continue
}
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)

View File

@@ -22,8 +22,10 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
@@ -60,6 +62,21 @@ func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
return nil
}
// ClearSignupTokens deletes signup tokens that have expired
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
// Delete tokens that are expired OR have reached their usage limit
st := j.db.
WithContext(ctx).
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
st := j.db.
@@ -88,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return nil
}
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
st := j.db.

View File

@@ -2,29 +2,36 @@ package job
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"path"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
)
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db}
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
// Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
err := s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
// Only necessary for file system storage
if fileStorage.Type() == storage.TypeFileSystem {
err = errors.Join(err, s.registerJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
}
return err
}
type FileCleanupJobs struct {
db *gorm.DB
db *gorm.DB
fileStorage storage.FileStorage
}
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
@@ -44,29 +51,24 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
initialsInUse[user.Initials()] = struct{}{}
}
defaultPicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults"
if _, err := os.Stat(defaultPicturesDir); os.IsNotExist(err) {
return nil
}
files, err := os.ReadDir(defaultPicturesDir)
defaultPicturesDir := path.Join("profile-pictures", "defaults")
files, err := j.fileStorage.List(ctx, defaultPicturesDir)
if err != nil {
return fmt.Errorf("failed to read default profile pictures directory: %w", err)
return fmt.Errorf("failed to list default profile pictures: %w", err)
}
filesDeleted := 0
for _, file := range files {
if file.IsDir() {
continue // Skip directories
_, filename := path.Split(file.Path)
if filename == "" {
continue
}
filename := file.Name()
initials := strings.TrimSuffix(filename, ".png")
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil {
filePath := path.Join(defaultPicturesDir, filename)
if err := j.fileStorage.Delete(ctx, filePath); err != nil {
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else {
filesDeleted++
@@ -77,3 +79,34 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil
}
// clearOrphanedTempFiles deletes temporary files that are produced by failed atomic writes
func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
const minAge = 10 * time.Minute
var deleted int
err := j.fileStorage.Walk(ctx, "/", func(p storage.ObjectInfo) error {
// Only temp files
if !strings.HasSuffix(p.Path, "-tmp") {
return nil
}
if time.Since(p.ModTime) < minAge {
return nil
}
if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
return nil
}
deleted++
return nil
})
if err != nil {
return fmt.Errorf("failed to scan storage: %w", err)
}
slog.Info("Done cleaning orphaned temp files", slog.Int("count", deleted))
return nil
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"github.com/gin-gonic/gin"
)
// CspMiddleware sets a Content Security Policy header and, when possible,
// includes a per-request nonce for inline scripts.
type CspMiddleware struct{}
func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} }
// GetCSPNonce returns the CSP nonce generated for this request, if any.
func GetCSPNonce(c *gin.Context) string {
if v, ok := c.Get("csp_nonce"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func (m *CspMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a random base64 nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
csp := "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"img-src * blob:;" +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'"
c.Writer.Header().Set("Content-Security-Policy", csp)
c.Next()
}
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "" // if generation fails, return empty; policy will omit nonce
}
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min":

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.IsTest() {
c.Next()
return
}

View File

@@ -8,6 +8,8 @@ import (
"strconv"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type AppConfigVariable struct {
@@ -32,22 +34,24 @@ 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
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public
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"`
@@ -58,7 +62,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"`
@@ -68,15 +72,16 @@ type AppConfig struct {
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"`
LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"`
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()
@@ -96,11 +101,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)
@@ -169,7 +179,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 {
@@ -183,7 +193,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

@@ -3,21 +3,22 @@ package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type AuditLog struct {
Base
Event AuditLogEvent `sortable:"true"`
IpAddress string `sortable:"true"`
Event AuditLogEvent `sortable:"true" filterable:"true"`
IpAddress *string `sortable:"true"`
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
Username string `gorm:"-"`
Data AuditLogData
UserID string
UserID string `filterable:"true"`
User User
}
@@ -28,10 +29,13 @@ 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"
AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION"
AuditLogEventPasskeyAdded AuditLogEvent = "PASSKEY_ADDED"
AuditLogEventPasskeyRemoved AuditLogEvent = "PASSKEY_REMOVED"
)
// Scan and Value methods for GORM to handle the custom type
@@ -46,14 +50,7 @@ func (e AuditLogEvent) Value() (driver.Value, error) {
}
func (d *AuditLogData) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, d)
case string:
return json.Unmarshal([]byte(v), d)
default:
return fmt.Errorf("unsupported type: %T", value)
}
return utils.UnmarshalJSONFromDatabase(d, value)
}
func (d AuditLogData) Value() (driver.Value, error) {

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

@@ -3,15 +3,16 @@ package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"gorm.io/gorm"
"strings"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type UserAuthorizedOidcClient struct {
Scope string
Scope string
LastUsedAt datatype.DateTime `sortable:"true"`
UserID string `gorm:"primary_key;"`
User User
@@ -19,6 +20,14 @@ type UserAuthorizedOidcClient struct {
Client OidcClient
}
func (c UserAuthorizedOidcClient) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
type OidcAuthorizationCode struct {
Base
@@ -38,19 +47,30 @@ 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
Credentials OidcClientCredentials
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
DarkImageType *string
IsPublic bool
PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool `sortable:"true" filterable:"true"`
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"`
}
func (c OidcClient) HasLogo() bool {
return c.ImageType != nil && *c.ImageType != ""
}
func (c OidcClient) HasDarkLogo() bool {
return c.DarkImageType != nil && *c.DarkImageType != ""
}
type OidcRefreshToken struct {
@@ -67,10 +87,12 @@ type OidcRefreshToken struct {
Client OidcClient
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
return nil
func (c OidcRefreshToken) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
type OidcClientCredentials struct { //nolint:recvcheck
@@ -99,14 +121,7 @@ func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (Oidc
}
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)
}
return utils.UnmarshalJSONFromDatabase(occ, value)
}
func (occ OidcClientCredentials) Value() (driver.Value, error) {
@@ -116,14 +131,7 @@ func (occ OidcClientCredentials) Value() (driver.Value, error) {
type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, cu)
case string:
return json.Unmarshal([]byte(v), cu)
default:
return fmt.Errorf("unsupported type: %T", value)
}
return utils.UnmarshalJSONFromDatabase(cu, value)
}
func (cu UrlList) Value() (driver.Value, error) {

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

@@ -0,0 +1,17 @@
package model
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type Storage struct {
Path string `gorm:"primaryKey"`
Data []byte
Size int64
ModTime datatype.DateTime
CreatedAt datatype.DateTime
}
func (Storage) TableName() string {
return "storage"
}

View File

@@ -11,6 +11,15 @@ 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 DateTimeFromString(str string) (DateTime, error) {
t, err := time.Parse(time.RFC3339Nano, str)
if err != nil {
return DateTime{}, fmt.Errorf("failed to parse date string: %w", err)
}
return DateTime(t), nil
}
func (date *DateTime) Scan(value any) (err error) {
switch v := value.(type) {
case time.Time:

View File

@@ -13,14 +13,15 @@ import (
type User struct {
Base
Username string `sortable:"true"`
Email string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
Username string `sortable:"true"`
Email *string `sortable:"true"`
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -31,7 +32,12 @@ func (u User) WebAuthnID() []byte { return []byte(u.ID) }
func (u User) WebAuthnName() string { return u.Username }
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName }
func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.FirstName + " " + u.LastName
}
func (u User) WebAuthnIcon() string { return "" }
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors
}
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
func (u User) FullName() string {
return u.FirstName + " " + u.LastName
}
func (u User) Initials() string {
first := utils.GetFirstCharacter(u.FirstName)

View File

@@ -3,11 +3,11 @@ package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"time"
"github.com/go-webauthn/webauthn/protocol"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type WebauthnSession struct {
@@ -16,6 +16,7 @@ type WebauthnSession struct {
Challenge string
ExpiresAt datatype.DateTime
UserVerification string
CredentialParams CredentialParameters
}
type WebauthnCredential struct {
@@ -45,20 +46,33 @@ 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
func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, atl)
case string:
return json.Unmarshal([]byte(v), atl)
default:
return fmt.Errorf("unsupported type: %T", value)
}
return utils.UnmarshalJSONFromDatabase(atl, value)
}
func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
return json.Marshal(atl)
}
type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
func (cp *CredentialParameters) Scan(value interface{}) error {
return utils.UnmarshalJSONFromDatabase(cp, value)
}
func (cp CredentialParameters) Value() (driver.Value, error) {
return json.Marshal(cp)
}

View File

@@ -25,14 +25,14 @@ func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
return &ApiKeyService{db: db, emailService: emailService}
}
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.ApiKey, utils.PaginationResponse, error) {
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&model.ApiKey{})
var apiKeys []model.ApiKey
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
@@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
apiKey := model.ApiKey{
Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: &input.Description,
ExpiresAt: datatype.DateTime(input.ExpiresAt),
Description: input.Description,
ExpiresAt: input.ExpiresAt,
UserID: userID,
}
@@ -144,9 +144,13 @@ func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey
}
}
if user.Email == nil {
return &common.UserEmailNotSetError{}
}
err := SendEmail(ctx, s.emailService, email.Address{
Name: user.FullName(),
Email: user.Email,
Email: *user.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(),

View File

@@ -4,17 +4,13 @@ import (
"context"
"errors"
"fmt"
"log"
"mime/multipart"
"os"
"reflect"
"slices"
"strings"
"sync/atomic"
"time"
"github.com/hashicorp/go-uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -29,22 +25,22 @@ type AppConfigService struct {
db *gorm.DB
}
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
func NewAppConfigService(ctx context.Context, db *gorm.DB) (*AppConfigService, error) {
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(ctx)
if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err)
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
}
err = service.initInstanceID(ctx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
}
return service
return service, nil
}
// GetDbConfig returns the application configuration.
@@ -63,17 +59,19 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// Values are the default ones
return &model.AppConfig{
// General
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""},
InstanceID: model.AppConfigVariable{Value: ""},
// Email
RequireUserEmail: model.AppConfigVariable{Value: "true"},
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
SmtpFrom: model.AppConfigVariable{},
@@ -99,11 +97,12 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeGroupName: model.AppConfigVariable{},
LdapAttributeAdminGroup: model.AppConfigVariable{},
LdapAdminGroupName: model.AppConfigVariable{},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
}
}
@@ -232,7 +231,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true)
res := cfg.ToAppConfigVariableSlice(true, false)
return res, nil
}
@@ -317,40 +316,7 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
}
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
return s.GetDbConfig().ToAppConfigVariableSlice(showAll)
}
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
fileType := utils.GetFileExtension(uploadedFile.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return &common.FileTypeNotSupportedError{}
}
// Save the updated image
imagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + fileType
err = utils.SaveFile(uploadedFile, imagePath)
if err != nil {
return err
}
// Delete the old image if it has a different file type, then update the type in the database
if fileType != oldImageType {
oldImagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + oldImageType
err = os.Remove(oldImagePath)
if err != nil {
return err
}
// Update the file type in the database
err = s.UpdateAppConfigValues(ctx, imageName+"ImageType", fileType)
if err != nil {
return err
}
}
return nil
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
}
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
@@ -368,7 +334,7 @@ func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// If the UI config is disabled, only load from the env
if common.EnvConfig.UiConfigDisabled {
dest, err := s.loadDbConfigFromEnv(ctx, s.db)
dest, err := s.loadDbConfigFromEnv(ctx, tx)
return dest, err
}
@@ -412,12 +378,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
field := rt.Field(i)
// Get the key and internal tag values
tagValue := strings.Split(field.Tag.Get("key"), ",")
key := tagValue[0]
isInternal := slices.Contains(tagValue, "internal")
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
// Internal fields are loaded from the database as they can't be set from the environment
if isInternal {
if attrs == "internal" {
var value string
err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}).
@@ -436,6 +400,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).FieldByName("Value").SetString(value)
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).FieldByName("Value").SetString(string(b))
continue
}
}
}

View File

@@ -4,10 +4,12 @@ import (
"sync/atomic"
"testing"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/stretchr/testify/require"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
@@ -22,7 +24,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
@@ -36,7 +38,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loads value from config table", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
@@ -66,7 +68,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
@@ -87,7 +89,7 @@ func TestLoadDbConfig(t *testing.T) {
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
@@ -129,7 +131,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -165,7 +167,7 @@ func TestLoadDbConfig(t *testing.T) {
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
@@ -189,7 +191,7 @@ func TestLoadDbConfig(t *testing.T) {
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -214,7 +216,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("update multiple values", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -258,7 +260,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -279,7 +281,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -295,7 +297,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
})
t.Run("error with invalid key", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -313,7 +315,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
@@ -386,7 +388,7 @@ func TestUpdateAppConfig(t *testing.T) {
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
@@ -451,7 +453,7 @@ func TestUpdateAppConfig(t *testing.T) {
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newDatabaseForTest(t)
db := testutils.NewDatabaseForTest(t)
service := &AppConfigService{
db: db,
}

View File

@@ -0,0 +1,123 @@
package service
import (
"context"
"fmt"
"io"
"mime/multipart"
"path"
"strings"
"sync"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type AppImagesService struct {
mu sync.RWMutex
extensions map[string]string
storage storage.FileStorage
}
func NewAppImagesService(extensions map[string]string, storage storage.FileStorage) *AppImagesService {
return &AppImagesService{extensions: extensions, storage: storage}
}
func (s *AppImagesService) GetImage(ctx context.Context, name string) (io.ReadCloser, int64, string, error) {
ext, err := s.getExtension(name)
if err != nil {
return nil, 0, "", err
}
mimeType := utils.GetImageMimeType(ext)
if mimeType == "" {
return nil, 0, "", fmt.Errorf("unsupported image type '%s'", ext)
}
imagePath := path.Join("application-images", name+"."+ext)
reader, size, err := s.storage.Open(ctx, imagePath)
if err != nil {
if storage.IsNotExist(err) {
return nil, 0, "", &common.ImageNotFoundError{}
}
return nil, 0, "", err
}
return reader, size, mimeType, nil
}
func (s *AppImagesService) UpdateImage(ctx context.Context, file *multipart.FileHeader, imageName string) error {
fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return &common.FileTypeNotSupportedError{}
}
s.mu.Lock()
defer s.mu.Unlock()
currentExt, ok := s.extensions[imageName]
if !ok {
s.extensions[imageName] = fileType
}
imagePath := path.Join("application-images", imageName+"."+fileType)
fileReader, err := file.Open()
if err != nil {
return err
}
defer fileReader.Close()
if err := s.storage.Save(ctx, imagePath, fileReader); err != nil {
return err
}
if currentExt != "" && currentExt != fileType {
oldImagePath := path.Join("application-images", imageName+"."+currentExt)
if err := s.storage.Delete(ctx, oldImagePath); err != nil {
return err
}
}
s.extensions[imageName] = fileType
return nil
}
func (s *AppImagesService) DeleteImage(ctx context.Context, imageName string) error {
s.mu.Lock()
defer s.mu.Unlock()
ext, ok := s.extensions[imageName]
if !ok || ext == "" {
return &common.ImageNotFoundError{}
}
imagePath := path.Join("application-images", imageName+"."+ext)
if err := s.storage.Delete(ctx, imagePath); err != nil {
return err
}
delete(s.extensions, imageName)
return nil
}
func (s *AppImagesService) IsDefaultProfilePictureSet() bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.extensions["default-profile-picture"]
return ok
}
func (s *AppImagesService) getExtension(name string) (string, error) {
s.mu.RLock()
defer s.mu.RUnlock()
ext, ok := s.extensions[name]
if !ok || ext == "" {
return "", &common.ImageNotFoundError{}
}
return strings.ToLower(ext), nil
}

View File

@@ -0,0 +1,114 @@
package service
import (
"bytes"
"context"
"io"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"path"
"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/storage"
)
func TestAppImagesService_GetImage(t *testing.T) {
store, err := storage.NewFilesystemStorage(t.TempDir())
require.NoError(t, err)
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "background.webp"), bytes.NewReader([]byte("data"))))
service := NewAppImagesService(map[string]string{"background": "webp"}, store)
reader, size, mimeType, err := service.GetImage(context.Background(), "background")
require.NoError(t, err)
defer reader.Close()
payload, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, []byte("data"), payload)
require.Equal(t, int64(len(payload)), size)
require.Equal(t, "image/webp", mimeType)
}
func TestAppImagesService_UpdateImage(t *testing.T) {
store, err := storage.NewFilesystemStorage(t.TempDir())
require.NoError(t, err)
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "logoLight.svg"), bytes.NewReader([]byte("old"))))
service := NewAppImagesService(map[string]string{"logoLight": "svg"}, store)
fileHeader := newFileHeader(t, "logoLight.png", []byte("new"))
require.NoError(t, service.UpdateImage(context.Background(), fileHeader, "logoLight"))
reader, _, err := store.Open(context.Background(), path.Join("application-images", "logoLight.png"))
require.NoError(t, err)
_ = reader.Close()
_, _, err = store.Open(context.Background(), path.Join("application-images", "logoLight.svg"))
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
store, err := storage.NewFilesystemStorage(t.TempDir())
require.NoError(t, err)
service := NewAppImagesService(map[string]string{}, store)
t.Run("get missing image returns not found", func(t *testing.T) {
_, _, _, err := service.GetImage(context.Background(), "missing")
require.Error(t, err)
var imageErr *common.ImageNotFoundError
assert.ErrorAs(t, err, &imageErr)
})
t.Run("reject unsupported file types", func(t *testing.T) {
err := service.UpdateImage(context.Background(), newFileHeader(t, "logo.txt", []byte("nope")), "logo")
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
assert.ErrorAs(t, err, &fileTypeErr)
})
t.Run("delete and extension tracking", func(t *testing.T) {
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "default-profile-picture.png"), bytes.NewReader([]byte("img"))))
service.extensions["default-profile-picture"] = "png"
require.NoError(t, service.DeleteImage(context.Background(), "default-profile-picture"))
assert.False(t, service.IsDefaultProfilePictureSet())
err := service.DeleteImage(context.Background(), "default-profile-picture")
require.Error(t, err)
var imageErr *common.ImageNotFoundError
assert.ErrorAs(t, err, &imageErr)
})
}
func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {
t.Helper()
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", filename)
require.NoError(t, err)
_, err = part.Write(content)
require.NoError(t, err)
require.NoError(t, writer.Close())
req := httptest.NewRequest(http.MethodPost, "/", body)
req.Header.Set("Content-Type", writer.FormDataContentType())
_, fileHeader, err := req.FormFile("file")
require.NoError(t, err)
return fileHeader
}

View File

@@ -0,0 +1,296 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrLockUnavailable = errors.New("lock is already held by another process")
ErrLockLost = errors.New("lock ownership lost")
)
const (
ttl = 30 * time.Second
renewInterval = 20 * time.Second
renewRetries = 3
lockKey = "application_lock"
)
type AppLockService struct {
db *gorm.DB
lockID string
processID int64
hostID string
}
func NewAppLockService(db *gorm.DB) *AppLockService {
host, err := os.Hostname()
if err != nil || host == "" {
host = "unknown-host"
}
return &AppLockService{
db: db,
processID: int64(os.Getpid()),
hostID: host,
lockID: uuid.NewString(),
}
}
type lockValue struct {
ProcessID int64 `json:"process_id"`
HostID string `json:"host_id"`
LockID string `json:"lock_id"`
ExpiresAt int64 `json:"expires_at"`
}
func (lv *lockValue) Marshal() (string, error) {
data, err := json.Marshal(lv)
if err != nil {
return "", err
}
return string(data), nil
}
func (lv *lockValue) Unmarshal(raw string) error {
if raw == "" {
return nil
}
return json.Unmarshal([]byte(raw), lv)
}
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var prevLockRaw string
err = tx.
WithContext(ctx).
Model(&model.KV{}).
Where("key = ?", lockKey).
Clauses(clause.Locking{Strength: "UPDATE"}).
Select("value").
Scan(&prevLockRaw).
Error
if err != nil {
return time.Time{}, fmt.Errorf("query existing lock: %w", err)
}
var prevLock lockValue
if prevLockRaw != "" {
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
}
}
now := time.Now()
nowUnix := now.Unix()
value := lockValue{
ProcessID: s.processID,
HostID: s.hostID,
LockID: s.lockID,
ExpiresAt: now.Add(ttl).Unix(),
}
raw, err := value.Marshal()
if err != nil {
return time.Time{}, fmt.Errorf("encode lock value: %w", err)
}
var query string
switch s.db.Name() {
case "sqlite":
query = `
INSERT INTO kv (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value
WHERE (json_extract(kv.value, '$.expires_at') < ?) OR ?
`
case "postgres":
query = `
INSERT INTO kv (key, value)
VALUES ($1, $2)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value
WHERE ((kv.value::json->>'expires_at')::bigint < $3) OR ($4::boolean IS TRUE)
`
default:
return time.Time{}, fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
res := tx.WithContext(ctx).Exec(query, lockKey, raw, nowUnix, force)
if res.Error != nil {
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
}
if err := tx.Commit().Error; err != nil {
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
}
// If there is a lock that is not expired and force is false, no rows will be affected
if res.RowsAffected == 0 {
return time.Time{}, ErrLockUnavailable
}
if force && prevLock.ExpiresAt > nowUnix && prevLock.LockID != s.lockID {
waitUntil = time.Unix(prevLock.ExpiresAt, 0)
}
attrs := []any{
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
}
if wait := time.Until(waitUntil); wait > 0 {
attrs = append(attrs, slog.Duration("wait_before_proceeding", wait))
}
slog.Info("Acquired application lock", attrs...)
return waitUntil, nil
}
// RunRenewal keeps renewing the lock until the context is canceled.
func (s *AppLockService) RunRenewal(ctx context.Context) error {
ticker := time.NewTicker(renewInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := s.renew(ctx); err != nil {
return fmt.Errorf("renew lock: %w", err)
}
}
}
}
// Release releases the lock if it is held by this process.
func (s *AppLockService) Release(ctx context.Context) error {
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var query string
switch s.db.Name() {
case "sqlite":
query = `
DELETE FROM kv
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
`
case "postgres":
query = `
DELETE FROM kv
WHERE key = $1
AND value::json->>'lock_id' = $2
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
if res.Error != nil {
return fmt.Errorf("release lock failed: %w", res.Error)
}
if res.RowsAffected == 0 {
slog.Warn("Application lock not held by this process, cannot release",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
)
}
slog.Info("Released application lock",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
)
return nil
}
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
func (s *AppLockService) renew(ctx context.Context) error {
var lastErr error
for attempt := 1; attempt <= renewRetries; attempt++ {
now := time.Now()
nowUnix := now.Unix()
expiresAt := now.Add(ttl).Unix()
value := lockValue{
LockID: s.lockID,
ProcessID: s.processID,
HostID: s.hostID,
ExpiresAt: expiresAt,
}
raw, err := value.Marshal()
if err != nil {
return fmt.Errorf("encode lock value: %w", err)
}
var query string
switch s.db.Name() {
case "sqlite":
query = `
UPDATE kv
SET value = ?
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ?
`
case "postgres":
query = `
UPDATE kv
SET value = $1
WHERE key = $2
AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4)
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
cancel()
switch {
case res.Error != nil:
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
case res.RowsAffected == 0:
// Must be after checking res.Error
return ErrLockLost
default:
slog.Debug("Renewed application lock",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
)
return nil
}
// Wait before next attempt or cancel if context is done
if attempt < renewRetries {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
}
return lastErr
}

View File

@@ -0,0 +1,189 @@
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService {
t.Helper()
return &AppLockService{
db: db,
processID: 1,
hostID: "test-host",
lockID: "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1",
}
}
func insertLock(t *testing.T, db *gorm.DB, value lockValue) {
t.Helper()
raw, err := value.Marshal()
require.NoError(t, err)
err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
require.NoError(t, err)
}
func readLockValue(t *testing.T, db *gorm.DB) lockValue {
t.Helper()
var row model.KV
err := db.Take(&row, "key = ?", lockKey).Error
require.NoError(t, err)
require.NotNil(t, row.Value)
var value lockValue
err = value.Unmarshal(*row.Value)
require.NoError(t, err)
return value
}
func TestAppLockServiceAcquire(t *testing.T) {
t.Run("creates new lock when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, service.processID, stored.ProcessID)
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 99,
HostID: "other-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
_, err := service.Acquire(context.Background(), false)
require.ErrorIs(t, err, ErrLockUnavailable)
current := readLockValue(t, db)
require.Equal(t, existing, current)
})
t.Run("force acquisition steals lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
insertLock(t, db, lockValue{
ProcessID: 99,
HostID: "other-host",
ExpiresAt: time.Now().Unix(),
})
_, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, service.processID, stored.ProcessID)
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
}
func TestAppLockServiceRelease(t *testing.T) {
t.Run("removes owned lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
err = service.Release(context.Background())
require.NoError(t, err)
var row model.KV
err = db.Take(&row, "key = ?", lockKey).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
})
t.Run("ignores lock held by another owner", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 2,
HostID: "other-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
err := service.Release(context.Background())
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, existing, stored)
})
}
func TestAppLockServiceRenew(t *testing.T) {
t.Run("extends expiration when lock is still owned", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
before := readLockValue(t, db)
err = service.renew(context.Background())
require.NoError(t, err)
after := readLockValue(t, db)
require.Equal(t, service.processID, after.ProcessID)
require.Equal(t, service.hostID, after.HostID)
require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt)
})
t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
err := service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
// Simulate a different process taking the lock.
newOwner := lockValue{
ProcessID: 9,
HostID: "stolen-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
raw, marshalErr := newOwner.Marshal()
require.NoError(t, marshalErr)
updateErr := db.Model(&model.KV{}).
Where("key = ?", lockKey).
Update("value", raw).Error
require.NoError(t, updateErr)
err = service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
}

View File

@@ -3,13 +3,13 @@ package service
import (
"context"
"fmt"
"log"
"log/slog"
userAgentParser "github.com/mileusna/useragent"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
)
@@ -21,19 +21,24 @@ type AuditLogService struct {
}
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
return &AuditLogService{
db: db,
appConfigService: appConfigService,
emailService: emailService,
geoliteService: geoliteService,
}
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) model.AuditLog {
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) {
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil {
log.Printf("Failed to get IP location: %v", err)
// Log the error but don't interrupt the operation
slog.Warn("Failed to get IP location", slog.String("ip", ipAddress), slog.Any("error", err))
}
auditLog := model.AuditLog{
Event: event,
IpAddress: ipAddress,
Country: country,
City: city,
UserAgent: userAgent,
@@ -41,33 +46,47 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
Data: data,
}
if ipAddress != "" {
// Only set ipAddress if not empty, because on Postgres we use INET columns that don't allow non-null empty values
auditLog.IpAddress = &ipAddress
}
// Save the audit log in the database
err = tx.
WithContext(ctx).
Create(&auditLog).
Error
if err != nil {
log.Printf("Failed to create audit log: %v", err)
return model.AuditLog{}
slog.Error("Failed to create audit log", "error", err)
return model.AuditLog{}, false
}
return auditLog
return auditLog, true
}
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
createdAuditLog := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
if !ok {
// At this point the transaction has been canceled already, and error has been logged
return createdAuditLog
}
// Count the number of times the user has logged in from the same device
var count int64
err := tx.
stmt := tx.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).
Count(&count).
Error
Where("user_id = ? AND user_agent = ?", userID, userAgent)
if ipAddress == "" {
// An empty IP address is stored as NULL in the database
stmt = stmt.Where("ip_address IS NULL")
} else {
stmt = stmt.Where("ip_address = ?", ipAddress)
}
err := stmt.Count(&count).Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
slog.ErrorContext(ctx, "Failed to count audit logs", slog.Any("error", err))
return createdAuditLog
}
@@ -76,7 +95,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
innerCtx := context.Background()
span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
// Note we don't use the transaction here because this is running in background
var user model.User
@@ -86,12 +106,17 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
First(&user).
Error
if innerErr != nil {
log.Printf("Failed to load user: %v", innerErr)
slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
return
}
if user.Email == nil {
return
}
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: user.Email,
Email: *user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,
Country: createdAuditLog.Country,
@@ -100,7 +125,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
DateTime: createdAuditLog.CreatedAt.UTC(),
})
if innerErr != nil {
log.Printf("Failed to send email to '%s': %v", user.Email, innerErr)
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", *user.Email))
return
}
}()
}
@@ -109,14 +135,14 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ?", userID)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
return logs, pagination, err
}
@@ -125,7 +151,7 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
return ua.Name + " on " + ua.OS + " " + ua.OSVersion
}
func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest, filters dto.AuditLogFilterDto) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.
@@ -133,25 +159,36 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
Preload("User").
Model(&model.AuditLog{})
if filters.UserID != "" {
query = query.Where("user_id = ?", filters.UserID)
}
if filters.Event != "" {
query = query.Where("event = ?", filters.Event)
}
if filters.ClientName != "" {
if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
dialect := s.db.Name()
switch dialect {
case "sqlite":
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
case "postgres":
query = query.Where("data->>'clientName' = ?", filters.ClientName)
query = query.Where("data->>'clientName' IN ?", clientName)
default:
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
}
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
if locations, ok := listRequestOptions.Filters["location"]; ok {
mapped := make([]string, 0, len(locations))
for _, v := range locations {
if s, ok := v.(string); ok {
switch s {
case "internal":
mapped = append(mapped, "Internal Network")
case "external":
mapped = append(mapped, "External Network")
}
}
}
if len(mapped) > 0 {
query = query.Where("country IN ?", mapped)
}
}
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
if err != nil {
return nil, pagination, err
}
@@ -164,8 +201,8 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s
WithContext(ctx).
Joins("User").
Model(&model.AuditLog{}).
Select("DISTINCT \"User\".id, \"User\".username").
Where("\"User\".username IS NOT NULL")
Select(`DISTINCT "User".id, "User".username`).
Where(`"User".username IS NOT NULL`)
type Result struct {
ID string `gorm:"column:id"`
@@ -173,7 +210,8 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s
}
var results []Result
if err := query.Find(&results).Error; err != nil {
err = query.Find(&results).Error
if err != nil {
return nil, fmt.Errorf("failed to query user IDs: %w", err)
}
@@ -209,7 +247,8 @@ func (s *AuditLogService) ListClientNames(ctx context.Context) (clientNames []st
}
var results []Result
if err := query.Find(&results).Error; err != nil {
err = query.Find(&results).Error
if err != nil {
return nil, fmt.Errorf("failed to query client IDs: %w", err)
}

View File

@@ -25,6 +25,7 @@ func isReservedClaim(key string) bool {
"name",
"email",
"preferred_username",
"display_name",
"groups",
TokenTypeClaim,
"sub",
@@ -55,16 +56,46 @@ const (
// UpdateCustomClaimsForUser updates the custom claims for a user
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(ctx, UserID, userID, claims)
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
return s.updateCustomClaims(ctx, UserGroupID, userGroupID, claims)
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
if err != nil {
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}
// updateCustomClaims updates the custom claims for a user or user group
func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
// Check for duplicate keys in the claims slice
seenKeys := make(map[string]struct{})
for _, claim := range claims {
@@ -74,11 +105,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
seenKeys[claim.Key] = struct{}{}
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var existingClaims []model.CustomClaim
err := tx.
WithContext(ctx).
@@ -150,11 +176,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
return nil, err
}
err = tx.Commit().Error
if err != nil {
return nil, err
}
return updatedClaims, nil
}

View File

@@ -7,16 +7,14 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"fmt"
"log"
"os"
"path/filepath"
"log/slog"
"path"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt"
"gorm.io/gorm"
@@ -24,7 +22,9 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
"github.com/pocket-id/pocket-id/backend/resources"
)
@@ -33,15 +33,19 @@ type TestService struct {
jwtService *JwtService
appConfigService *AppConfigService
ldapService *LdapService
fileStorage storage.FileStorage
appLockService *AppLockService
externalIdPKey jwk.Key
}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) (*TestService, error) {
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
appLockService: appLockService,
fileStorage: fileStorage,
}
err := s.initExternalIdP()
if err != nil {
@@ -60,7 +64,7 @@ func (s *TestService) initExternalIdP() error {
return fmt.Errorf("failed to generate private key: %w", err)
}
s.externalIdPKey, err = utils.ImportRawKey(rawKey)
s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
if err != nil {
return fmt.Errorf("failed to import private key: %w", err)
}
@@ -76,21 +80,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
},
Username: "tim",
Email: "tim.cook@test.com",
FirstName: "Tim",
LastName: "Cook",
IsAdmin: true,
Username: "tim",
Email: utils.Ptr("tim.cook@test.com"),
FirstName: "Tim",
LastName: "Cook",
DisplayName: "Tim Cook",
IsAdmin: true,
},
{
Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
},
Username: "craig",
Email: "craig.federighi@test.com",
FirstName: "Craig",
LastName: "Federighi",
IsAdmin: false,
Username: "craig",
Email: utils.Ptr("craig.federighi@test.com"),
FirstName: "Craig",
LastName: "Federighi",
DisplayName: "Craig Federighi",
IsAdmin: false,
},
}
for _, user := range users {
@@ -152,11 +158,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
},
Name: "Nextcloud",
LaunchURL: utils.Ptr("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID,
CreatedByID: utils.Ptr(users[0].ID),
},
{
Base: model.Base{
@@ -165,11 +172,21 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: users[1].ID,
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{
userGroups[1],
},
},
{
Base: model.Base{
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
},
Name: "Tailscale",
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
CreatedByID: utils.Ptr(users[0].ID),
},
{
Base: model.Base{
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
@@ -177,7 +194,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: users[1].ID,
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{
@@ -243,14 +260,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userAuthorizedClients := []model.UserAuthorizedOidcClient{
{
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[0].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[2].ID,
Scope: "openid profile email",
UserID: users[0].ID,
ClientID: oidcClients[2].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
},
}
for _, userAuthorizedClient := range userAuthorizedClients {
@@ -263,8 +288,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
publicKeyPasskey1, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKeyPasskey2, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
publicKeyPasskey1, _ := base64.StdEncoding.DecodeString("pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=")
publicKeyPasskey2, _ := base64.StdEncoding.DecodeString("pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=")
webauthnCredentials := []model.WebauthnCredential{
{
Name: "Passkey 1",
@@ -293,6 +318,10 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Challenge: "challenge",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserVerification: "preferred",
CredentialParams: model.CredentialParameters{
{Type: "public-key", Algorithm: -7},
{Type: "public-key", Algorithm: -257},
},
}
if err := tx.Create(&webauthnSession).Error; err != nil {
return err
@@ -302,14 +331,73 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
}
if err := tx.Create(&apiKey).Error; err != nil {
return err
}
signupTokens := []model.SignupToken{
{
Base: model.Base{
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
},
Token: "VALID1234567890A",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
},
{
Base: model.Base{
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
},
Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
UsageLimit: 5,
UsageCount: 2,
},
{
Base: model.Base{
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
},
Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
UsageLimit: 3,
UsageCount: 1,
},
{
Base: model.Base{
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
},
Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 1, // Usage limit reached
},
}
for _, token := range signupTokens {
if err := tx.Create(&token).Error; err != nil {
return err
}
}
keyValues := []model.KV{
{
Key: jwkutils.PrivateKeyDBKey,
// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
Value: utils.Ptr("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
},
}
for _, kv := range keyValues {
if err := tx.Create(&kv).Error; err != nil {
return err
}
}
return nil
})
@@ -356,9 +444,10 @@ func (s *TestService) ResetDatabase() error {
return err
}
func (s *TestService) ResetApplicationImages() error {
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
log.Printf("Error removing directory: %v", err)
func (s *TestService) ResetApplicationImages(ctx context.Context) error {
err := s.fileStorage.DeleteAll(ctx, "/")
if err != nil {
slog.ErrorContext(ctx, "Error removing uploads", slog.Any("error", err))
return err
}
@@ -368,13 +457,20 @@ func (s *TestService) ResetApplicationImages() error {
}
for _, file := range files {
srcFilePath := filepath.Join("images", file.Name())
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name())
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if file.IsDir() {
continue
}
srcFilePath := path.Join("images", file.Name())
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil {
return err
}
err = s.fileStorage.Save(ctx, path.Join("application-images", file.Name()), srcFile)
if err != nil {
srcFile.Close()
return err
}
srcFile.Close()
}
return nil
@@ -387,47 +483,29 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Manually set instance ID
err = s.appConfigService.UpdateAppConfigValues(ctx, "instanceId", "test-instance-id")
if err != nil {
return err
}
// Reload the app config from the database after resetting the values
return s.appConfigService.LoadDbConfig(ctx)
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {
return err
}
// Reload the JWK
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
return err
}
return nil
}
func (s *TestService) SetJWTKeys() {
const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`
privateKey, _ := jwk.ParseKey([]byte(privateKeyString))
_ = s.jwtService.SetKey(privateKey)
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
}
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA public key")
}
coseKey := map[int]interface{}{
1: 2, // Key type: EC2
3: -7, // Algorithm: ECDSA with SHA-256
-1: 1, // Curve: P-256
-2: ecdsaPubKey.X.Bytes(), // X coordinate
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
}
cborPublicKey, err := cbor.Marshal(coseKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal COSE key: %w", err)
}
return cborPublicKey, nil
func (s *TestService) ResetLock(ctx context.Context) error {
_, err := s.appLockService.Acquire(ctx, true)
return err
}
// SyncLdap triggers an LDAP synchronization
@@ -454,7 +532,7 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
"ldapAttributeGroupUniqueIdentifier": "uuid",
"ldapAttributeGroupName": "uid",
"ldapAttributeGroupMember": "member",
"ldapAttributeAdminGroup": "admin_group",
"ldapAdminGroupName": "admin_group",
"ldapSoftDeleteUsers": "true",
"ldapEnabled": "true",
}

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