Compare commits

...

391 Commits

Author SHA1 Message Date
Elias Schneider
23827ba1d1 release: 1.4.1 2025-06-22 21:30:07 +02:00
Elias Schneider
7d36bda769 fix: app not starting if UI config is disabled and Postgres is used 2025-06-22 21:21:14 +02:00
Manuel Rais
8c559ea067 chore(translations) : typo in french language (#669) 2025-06-22 18:58:59 +00:00
Elias Schneider
88832d4bc9 chore(translations): update translations via Crowdin (#663) 2025-06-20 11:11:42 +02:00
Kyle Mendell
f5cece3b0e release: 1.4.0 2025-06-19 13:21:49 -05:00
Kyle Mendell
d5485238b8 feat: configurable local ipv6 ranges for audit log (#657) 2025-06-19 19:56:27 +02:00
Kyle Mendell
ac5a121f66 feat: location filter for global audit log (#662) 2025-06-19 17:12:53 +00:00
Elias Schneider
481df3bcb9 chore: add configuration for backend hot reloading 2025-06-19 18:45:01 +02:00
Mr Snake
7677a3de2c feat: allow setting unix socket mode (#661) 2025-06-18 18:41:57 +02:00
Elias Schneider
1f65c01b04 chore(translations): update translations via Crowdin (#659) 2025-06-18 09:11:36 -05:00
Elias Schneider
d5928f6fea chore: remove unused crypto util 2025-06-17 17:55:52 +02:00
Elias Schneider
bef77ac8dc fix: use inline style for dynamic background image URL instead of Tailwind class 2025-06-17 13:10:02 +02:00
Elias Schneider
c8eb034c49 chore: use v1 tag in example docker-compose.yml 2025-06-16 23:31:16 +02:00
Elias Schneider
c77167df46 ci/cd: cancel build-next action if new one starts 2025-06-16 16:12:33 +02:00
Elias Schneider
3717a663d9 ci/cd: only build required binaries for next image 2025-06-16 16:09:09 +02:00
Elias Schneider
5814549cbe refactor: run formatter 2025-06-16 16:06:11 +02:00
Elias Schneider
2e5d268798 fix: explicitly cache images to prevent unexpected behavior 2025-06-16 15:59:14 +02:00
Elias Schneider
4ed312251e chore(translations): update translations via Crowdin (#652) 2025-06-15 09:57:08 -05:00
Elias Schneider
946c534b08 fix: center oidc client images if they are smaller than the box 2025-06-14 19:33:40 +02:00
Kyle Mendell
883877adec feat: ui accent colors (#643)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-13 07:06:54 -05:00
Elias Schneider
215531d65c feat: use icon instead of text on application image update hover state 2025-06-13 12:07:14 +02:00
Elias Schneider
c0f055c3c0 chore(translations): update translations via Crowdin (#649) 2025-06-10 22:01:50 -05:00
Alessandro (Ale) Segala
d77044882d fix: reduce duration of animations on login and signin page (#648) 2025-06-10 21:14:55 +02:00
Alessandro (Ale) Segala
d6795300b1 feat: auto-focus on the login buttons (#647) 2025-06-10 21:13:36 +02:00
Elias Schneider
fd3c76ffa3 refactor: run formatter 2025-06-10 14:43:56 +02:00
Amazingca
698bc3a35a chore(translations): Update spelling and grammar in en.json (#650) 2025-06-10 07:34:49 -05:00
Elias Schneider
1bcb50edc3 fix: allow images with uppercase file extension 2025-06-10 11:11:03 +02:00
Elias Schneider
9700afb9cb chore(translations): update translations via Crowdin (#644) 2025-06-10 10:36:52 +02:00
Elias Schneider
9ce82fb205 release: 1.3.1 2025-06-09 22:59:13 +02:00
Elias Schneider
2935236ace fix: change timestamp of client_credentials.sql migration 2025-06-09 22:58:56 +02:00
Elias Schneider
c821b675b8 release: 1.3.0 2025-06-09 21:37:27 +02:00
Elias Schneider
a09d529027 chore: add branch check to release script 2025-06-09 21:37:00 +02:00
Alessandro (Ale) Segala
b62b61fb01 feat: allow introspection and device code endpoints to use Federated Client Credentials (#640) 2025-06-09 21:17:55 +02:00
Alessandro (Ale) Segala
df5c1ed1f8 chore: add docs link and rename to Federated Client Credentials (#636)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 21:15:37 +02:00
Elias Schneider
f4af35f86b chore(translations): update translations via Crowdin (#642) 2025-06-09 18:36:37 +02:00
Elias Schneider
657a51f7ed fix: misleading text for disable animations option 2025-06-09 18:22:55 +02:00
Elias Schneider
575b2f71e9 fix: use full width for audit log filters 2025-06-09 18:16:53 +02:00
Elias Schneider
97f7326da4 feat: new color theme for the UI 2025-06-09 18:09:13 +02:00
Elias Schneider
242d87a54b chore: upgrade to Shadcn v1.0.0 2025-06-09 18:08:39 +02:00
Kyle Mendell
c111b79147 feat: oidc client data preview (#624)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-09 15:46:03 +00:00
Elias Schneider
61bf14225b chore(translations): update translations via Crowdin (#637) 2025-06-09 11:58:14 +02:00
github-actions[bot]
c1e98411b6 chore: update AAGUIDs (#639)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-06-09 11:48:21 +02:00
Elias Schneider
b25e95fc4a ci/cd: add missing attestions permission 2025-06-08 16:15:23 +02:00
Elias Schneider
3cc82d8522 docs: remove difficult to maintain OpenAPI properties 2025-06-08 16:10:42 +02:00
Elias Schneider
ea4e48680c docs: fix pagination API docs 2025-06-08 16:04:58 +02:00
Elias Schneider
f403eed12c ci/cd: add missing permission 2025-06-08 16:03:40 +02:00
Elias Schneider
388a874922 refactor: upgrade to Zod v4 (#623) 2025-06-08 15:44:59 +02:00
Elias Schneider
9a4aab465a chore(translations): update translations via Crowdin (#632) 2025-06-08 15:44:22 +02:00
Kyle Mendell
a052cd6619 ci/cd: add workflow for building 'next' docker image (#633)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-08 15:42:41 +02:00
Elias Schneider
31a803b243 chore(translations): add Traditional Chinese files 2025-06-07 21:04:48 +02:00
Elias Schneider
1d2e41c04e chore(translations): update translations via Crowdin (#629) 2025-06-07 21:01:49 +02:00
Elias Schneider
b650d6d423 chore(translations): add Danish language files 2025-06-06 16:27:33 +02:00
Elias Schneider
156aad3057 chore(translations): update translations via Crowdin (#620) 2025-06-06 16:25:37 +02:00
Alessandro (Ale) Segala
05bfe00924 feat: JWT bearer assertions for client authentication (#566)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-06-06 12:23:51 +02:00
Mr Snake
035b2c022b feat: add unix socket support (#615) 2025-06-06 07:01:19 +00:00
Elias Schneider
61b62d4612 fix: OIDC client image can't be deleted 2025-06-06 08:50:33 +02:00
Elias Schneider
dc5d7bb2f3 refactor: run fomratter 2025-06-05 22:43:24 +02:00
Elias Schneider
5e9096e328 fix: UI config overridden by env variables don't apply on first start 2025-06-05 22:36:55 +02:00
Elias Schneider
34b4ba514f chore(translations): update translations via Crowdin (#614) 2025-06-05 15:42:28 +02:00
Elias Schneider
d217083059 feat: add API endpoint for user authorized clients 2025-06-04 09:23:44 +02:00
Elias Schneider
bdcef60cab fix: don't load app config and user on every route change 2025-06-04 08:52:34 +02:00
Elias Schneider
14f59ce3f3 release: 1.2.0 2025-06-03 22:33:40 +02:00
Elias Schneider
31ad904367 fix: page scrolls up on form submisssion 2025-06-03 21:12:21 +02:00
Elias Schneider
04fcf1110e fix: improve spacing on auth screens 2025-06-03 21:09:32 +02:00
Elias Schneider
eb9b6433ae chore(translations): update translations via Crowdin (#606) 2025-06-02 15:58:52 +02:00
Elias Schneider
b9489b5e9a fix: whitelist authorization header for CORS 2025-06-02 15:55:29 +02:00
Elias Schneider
bd1c69b7b7 Update Crowdin configuration file 2025-06-02 14:17:21 +02:00
Elias Schneider
23dc235bac Update Crowdin configuration file 2025-06-02 14:13:16 +02:00
Elias Schneider
2440379cd1 fix: fallback to primary language if no translation available for specific country 2025-06-02 14:08:32 +02:00
Elias Schneider
6c00aaa3ef fix: allow users to update their locale even when own account update disabled 2025-06-02 11:35:13 +02:00
Elias Schneider
00259f8819 tests: adapt unit test for new app config default value behavior 2025-06-01 20:54:53 +02:00
Elias Schneider
decf8ec70b fix: clear default app config variables from database 2025-06-01 20:46:44 +02:00
Elias Schneider
c24a5546a5 docs: use https in .env.example 2025-05-31 20:51:55 +02:00
Elias Schneider
312421d777 chore(translations): update translations via Crowdin (#599) 2025-05-31 18:44:34 +02:00
Elias Schneider
c42a29a66c chore(translations): update translations via Crowdin (#593) 2025-05-30 21:56:28 -05:00
Elias Schneider
afc317adf7 chore(translations): update translations via Crowdin (#590) 2025-05-29 23:47:58 +02:00
Alessandro (Ale) Segala
256f74d0a3 fix: don't use TOFU for logout callback URLs (#588) 2025-05-29 22:01:23 +02:00
Kyle Mendell
20d3f780a2 feat: auto detect callback url (#583)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-29 17:16:10 +02:00
Alessandro (Ale) Segala
6d6dc6646a fix: run jobs at interval instead of specific time (#585) 2025-05-29 17:15:35 +02:00
Alessandro (Ale) Segala
3d402fc0ca fix: small fixes in analytics_job (#582) 2025-05-28 11:12:44 -05:00
Kyle Mendell
b874681824 fix: show LAN for auditlog location for internal networks 2025-05-28 10:52:40 -05:00
Elias Schneider
97cbdfb1ef chore(translations): update translations via Crowdin (#579) 2025-05-28 10:21:03 -05:00
Elias Schneider
24889f9ebc release: 1.1.0 2025-05-28 11:30:09 +02:00
Elias Schneider
e0ec607198 feat: add daily heartbeat request for counting Pocket ID instances (#578) 2025-05-28 11:19:45 +02:00
Maxim Baz
d29fca155e ci/cd: tag container images with v{major} (#577) 2025-05-27 14:01:49 +02:00
Elias Schneider
e2e26b53b3 chore(translations): update translations via Crowdin (#575) 2025-05-26 11:07:40 +02:00
github-actions[bot]
948efbd9c1 chore: update AAGUIDs (#576)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-26 11:06:41 +02:00
Elias Schneider
f03b80f9d7 fix: run user group count inside a transaction 2025-05-25 22:24:28 +02:00
Kyle Mendell
38d7ee4432 feat: show allowed group count on oidc client list (#567)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 19:22:25 +00:00
Kyle Mendell
f66e8e8b44 fix: use ldapAttributeUserUsername for finding group members (#565)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-25 20:37:17 +02:00
Elias Schneider
ee133dbceb chore(translations): update translations via Crowdin (#573) 2025-05-25 20:14:46 +02:00
Elias Schneider
68e4b67bd2 feat: require user verification for passkey sign in 2025-05-25 17:09:05 +02:00
Elias Schneider
553147a1c0 release: 1.0.0 2025-05-25 00:06:40 +02:00
Elias Schneider
31ae8cac96 ci/cd: fix subject digest in container image attestation 2025-05-25 00:06:21 +02:00
Elias Schneider
7691622274 chore: remove default value from TARGETARCH in Dockerfile 2025-05-24 23:49:07 +02:00
Elias Schneider
5d1be367c3 chore(translations): update translations via Crowdin (#561) 2025-05-24 23:24:06 +02:00
Elias Schneider
ed0e566e99 ci/cd: upgrade build-push-action 2025-05-24 23:23:48 +02:00
Elias Schneider
2793eb4ebd chore: add major flag to release script 2025-05-24 23:00:33 +02:00
Elias Schneider
059073d4c2 fix: trim whitespaces from string inputs 2025-05-24 22:55:46 +02:00
Elias Schneider
e19b33fc2e fix: use same color as title for description in alert 2025-05-24 22:55:46 +02:00
Elias Schneider
cbe7aa6eec docs: adapt contribution guide 2025-05-24 22:55:46 +02:00
Elias Schneider
2a457ac8e9 chore: remove unused data.json 2025-05-24 22:55:46 +02:00
Elias Schneider
0d4d5386c7 chore: exclude binary from project root 2025-05-24 22:55:46 +02:00
Elias Schneider
f820fc8301 fix: use pointer cursor for menu items 2025-05-24 22:55:46 +02:00
Elias Schneider
131f470757 fix: show correct app name on sign out page 2025-05-24 22:55:46 +02:00
Elias Schneider
6c35570e78 fix: add back month and year selection for date picker 2025-05-24 22:55:46 +02:00
Elias Schneider
869c4c5871 tests: fix e2e tests after shadcn upgrade 2025-05-24 22:55:46 +02:00
Elias Schneider
a65c0b3da3 chore: add missing types to Playwright tests 2025-05-24 22:55:46 +02:00
Elias Schneider
3042de2ce1 tests: fix lldap setup if data already seeded 2025-05-24 22:55:46 +02:00
Elias Schneider
f57c8d347e fix: remove nested button in user group list 2025-05-24 22:55:46 +02:00
Elias Schneider
5b3ff7b879 tests: fix change locale test 2025-05-24 22:55:46 +02:00
Elias Schneider
9fff6ec3b6 tests: move auth.setup.ts into specs folder 2025-05-24 22:55:46 +02:00
Elias Schneider
ca5e754aea ci/cd: fix .auth path of e2e tests 2025-05-24 22:55:46 +02:00
Elias Schneider
ebcf861aa6 ci/cd: start test containers with Docker Compose 2025-05-24 22:55:46 +02:00
Elias Schneider
966a566ade refactor: move e2e tests to root of repository 2025-05-24 22:55:46 +02:00
Elias Schneider
5fa15f6098 fix: remove curly bracket from user group URL 2025-05-24 22:55:46 +02:00
Kyle Mendell
53f212fd3a tests: wait for network 2025-05-24 22:55:46 +02:00
Kyle Mendell
21cb3310d6 tests: use bits-10 as selector 2025-05-24 22:55:46 +02:00
Kyle Mendell
4dc0b2f37f fix: ldap tests 2025-05-24 22:55:46 +02:00
Elias Schneider
ac6df536ef tests: adapt e2e tests 2025-05-24 22:55:46 +02:00
Elias Schneider
c37386f8b2 feat: improve buttons styling 2025-05-24 22:55:46 +02:00
Elias Schneider
c3a03db8b0 fix: authorize page doesn't load 2025-05-24 22:55:46 +02:00
Kyle Mendell
28c85990ba refactor: migrate shadcn-components to Svelte 5 and TW4 (#551)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-24 22:55:46 +02:00
Elias Schneider
05b443d984 chore: add .well-known to development reverse proxy 2025-05-24 22:55:46 +02:00
Kyle Mendell
8326bfd136 chore: add pocket-id to .gitignore 2025-05-24 22:55:46 +02:00
Kyle Mendell
b2e89934de chore: remove pocket-id binary 2025-05-24 22:55:46 +02:00
Kyle Mendell
c726c1621b fix: animation speed set to max of 300ms 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
b71c84c355 refactor: some clean-up in OIDC service and controller (#550) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
3896b7bb3b chore: address linter's complaint in 1.0 branch (#546) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
cb2a9f9f7d refactor: replace create-one-time-access-token script with in-app functionality (#540) 2025-05-24 22:55:46 +02:00
Alessandro (Ale) Segala
35b227cd17 ci/cd: update release pipelines (#541) 2025-05-24 22:55:46 +02:00
Elias Schneider
f8a7467ec0 refactor!: serve the static frontend trough the backend (#520)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-05-24 22:55:46 +02:00
Elias Schneider
bf710aec56 fix: custom logo not correctly loaded if UI configuration is disabled 2025-05-22 19:07:34 +02:00
Elias Schneider
005702e5b6 chore(translations): update translations via Crowdin (#556) 2025-05-21 15:15:21 +02:00
Patryk Mikołajczyk
66d47bf933 chore(translations): add Polish translations (#554)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-21 15:13:07 +02:00
github-actions[bot]
d6104bbb35 chore: update AAGUIDs (#547)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-18 21:23:43 -05:00
Jake Howard
a0e22036c8 refactor: update options API for simplewebauthn (#543) 2025-05-18 13:22:04 +02:00
Alessandro (Ale) Segala
21bf49c061 refactor: flaky unit test in db_bootstrap_test (#532) 2025-05-14 10:54:03 -05:00
Alessandro (Ale) Segala
a408ef797b refactor: switch SQLite driver to pure-Go implementation (#530) 2025-05-14 09:29:04 +02:00
Kyle Mendell
f1154257c5 refactor!: remove old DB env variables, and jwk migrations logic (#529) 2025-05-13 23:05:54 +02:00
github-actions[bot]
0ca78bef8d chore: update AAGUIDs (#523)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-12 07:05:16 +02:00
Elias Schneider
dc5968cd30 release: 0.53.0 2025-05-08 21:56:49 +02:00
Elias Schneider
63a0c08696 fix: handle CORS correctly for endpoints that SPAs need (#513) 2025-05-08 21:56:17 +02:00
Elias Schneider
6c415e7769 chore(translations): update translations via Crowdin (#517) 2025-05-08 20:48:45 +02:00
Elias Schneider
90bdd29fb6 ci/cd: add explicit permissions to actions 2025-05-07 16:48:18 +02:00
Elias Schneider
e0db4695ac refactor: run formatter 2025-05-07 16:43:24 +02:00
Elias Schneider
de648dd6da ci/cd: remove wait for LDAP sync 2025-05-07 16:40:10 +02:00
Kyle Mendell
73c82ae43a tests: add e2e LDAP tests (#466)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-05-07 14:38:02 +00:00
Elias Schneider
ba256c76bc refactor: organize imports 2025-05-07 09:58:38 +02:00
Elias Schneider
5e2e947fe0 feat: add support for TZ environment variable 2025-05-07 09:55:30 +02:00
Elias Schneider
f4281e4f69 release: 0.52.0 2025-05-06 22:14:39 +02:00
Alessandro (Ale) Segala
3c87e4ec14 feat: add healthz endpoint (#494) 2025-05-06 22:14:18 +02:00
Elias Schneider
c55fef057c fix: correctly set script permissions inside Docker container 2025-05-06 21:18:45 +02:00
Daenney
6f54ee5d66 feat: OpenTelemetry tracing and metrics (#262) (#495)
Co-authored-by: ItalyPaleAle <43508+ItalyPaleAle@users.noreply.github.com>
2025-05-05 15:59:44 +02:00
github-actions[bot]
9efab5f3e8 chore: update AAGUIDs (#507)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-05-05 15:38:26 +02:00
Elias Schneider
364f5b38b9 ci/cd: create a PR instead of commiting for update aaguids workflow 2025-05-05 09:38:55 +02:00
Kyle Mendell
5d78445501 ci/cd: build frontend to include paraglide before running svelte-check 2025-05-04 10:08:01 -05:00
Kyle Mendell
8ec2388269 ci/cd: add svelte-check workflow for the frontend 2025-05-03 21:48:25 -05:00
Elias Schneider
dbacdb5bf0 release: 0.51.1 2025-05-03 23:42:47 +02:00
Elias Schneider
f4c6cff461 refactor: fix type errors 2025-05-03 23:42:17 +02:00
Elias Schneider
0b9cbf47e3 fix: allow LDAP users to update their locale 2025-05-03 23:32:56 +02:00
Alessandro (Ale) Segala
bda178c2bb refactor: complete graceful shutdown implementation and add service runner (#493) 2025-05-03 23:25:22 +02:00
Elias Schneider
6bd6cefaa6 fix: non admin users weren't able to call the end session endpoint 2025-05-03 22:53:55 +02:00
dependabot[bot]
83be1e0b49 chore(deps-dev): bump vite from 6.2.6 to 6.3.4 in /frontend in the npm_and_yarn group across 1 directory (#496)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-30 14:53:30 -05:00
Kyle Mendell
cf3fe0be84 fix: last name still showing as required on account form (#492)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-29 20:24:16 +02:00
Elias Schneider
ec76e1c111 chore(translations): update translations via Crowdin (#491) 2025-04-29 00:04:12 -05:00
Elias Schneider
6004f84845 release: 0.51.0 2025-04-28 11:15:52 +02:00
Alessandro (Ale) Segala
3ec98736cf refactor: graceful shutdown for server (#482) 2025-04-28 11:13:50 +02:00
Elias Schneider
ce24372c57 fix: do not require PKCE for public clients 2025-04-28 11:02:35 +02:00
Elias Schneider
4614769b84 refactor: reorganize imports 2025-04-28 10:49:54 +02:00
Elias Schneider
86d2b5f59f fix: return correct error message if user isn't authorized 2025-04-28 10:39:17 +02:00
Elias Schneider
1efd1d182d fix: hide global audit log switch for non admin users 2025-04-28 10:38:53 +02:00
Elias Schneider
0a24ab8001 fix: updating scopes of an authorized client fails with Postgres 2025-04-28 09:29:18 +02:00
James18232
02cacba5c5 feat: new login code card position for mobile devices (#452)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-28 04:04:48 +00:00
Elias Schneider
38653e2aa4 chore(translations): update translations via Crowdin (#485) 2025-04-27 23:00:37 -05:00
Elias Schneider
8cc9b159a5 release: 0.50.0 2025-04-27 21:58:28 +02:00
James Baker
990c8af3d1 Fix incorrectly swapped refreshToken and accessToken (#490) 2025-04-27 14:09:07 -05:00
Alessandro (Ale) Segala
4c33793678 fix: pass context to methods that were missing it (#487) 2025-04-26 12:32:42 -05:00
Elias Schneider
9e06f70380 chore(translations): update translations via Crowdin (#479)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-25 12:21:59 -05:00
Kyle Mendell
22f7d64bf0 feat: device authorization endpoint (#270)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-25 12:14:51 -05:00
Kyle Mendell
630327c979 feat: make family name optional (#476) 2025-04-25 09:52:09 -05:00
Alessandro (Ale) Segala
662506260e refactor: do not force redirects to happen on the server (#481) 2025-04-24 21:09:52 +02:00
Star_caorui
8e66af627a chore(translations): Add Simplified Chinese translation. (#473) 2025-04-23 18:39:37 +00:00
Alessandro (Ale) Segala
270c30334d fix: prevent deadlock when trying to delete LDAP users (#471) 2025-04-22 15:16:44 +02:00
Elias Schneider
c73c3ceb5e chore(translations): update translations via Crowdin (#468) 2025-04-21 23:02:10 -05:00
Alessandro (Ale) Segala
22725d30f4 fix: do not override XDG_DATA_HOME/XDG_CONFIG_HOME if they are already set (#472) 2025-04-21 22:58:32 -05:00
eiqnepm
76b753f9f2 fix: rootless Caddy data and configuration (#470) 2025-04-21 13:15:51 +02:00
Elias Schneider
453a765107 release: 0.49.0 2025-04-20 20:00:09 +02:00
Elias Schneider
f03645d545 chore(translations): update translations via Crowdin (#467) 2025-04-20 17:59:49 +00:00
Elias Schneider
55273d68c9 chore(translations): fix typo in key 2025-04-20 19:51:12 +02:00
Elias Schneider
4e05b82f02 fix: hide alternative sign in button if user is already authenticated 2025-04-20 19:03:58 +02:00
Elias Schneider
2597907578 refactor: fix type errors 2025-04-20 18:54:45 +02:00
Kyle Mendell
debef9a66b ci/cd: setup caching and improve ci job performance (#465) 2025-04-20 11:48:46 -05:00
Elias Schneider
9122e75101 feat: add ability to disable API key expiration email 2025-04-20 18:41:03 +02:00
Elias Schneider
fe1c4b18cd feat: add ability to send login code via email (#457)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-20 18:32:40 +02:00
Elias Schneider
e571996cb5 fix: disable animations not respected on authorize and logout page 2025-04-20 17:04:00 +02:00
Elias Schneider
fb862d3ec3 chore(translations): update translations via Crowdin (#459)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-20 09:43:27 -05:00
Kyle Mendell
26f01f205b feat: send email to user when api key expires within 7 days (#451)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-20 14:40:20 +00:00
Elias Schneider
c37a3e0ed1 fix: remove limit of 20 callback URLs 2025-04-20 16:32:11 +02:00
Elias Schneider
eb689eb56e feat: add description to callback URL inputs 2025-04-20 00:32:27 +02:00
Elias Schneider
60bad9e985 fix: locale change in dropdown doesn't work on first try 2025-04-20 00:31:33 +02:00
Elias Schneider
e21ee8a871 chore: add kmendell to FUNDING.yml 2025-04-19 18:51:01 +02:00
Elias Schneider
04006eb5cc release: 0.48.0 2025-04-18 18:34:52 +02:00
Elias Schneider
84f1d5c906 fix: user querying fails on global audit log page with Postgres 2025-04-18 18:33:14 +02:00
Elias Schneider
983e989be1 chore(translations): update translations via Crowdin (#456) 2025-04-18 18:21:04 +02:00
Kyle Mendell
c843a60131 feat: disable/enable users (#437)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-18 15:38:50 +00:00
Elias Schneider
56a8b5d0c0 feat: add gif support for logo and background image 2025-04-18 17:31:04 +02:00
Elias Schneider
f0dce41fbc fix: callback URL doesn't get rejected if it starts with a different string 2025-04-17 20:52:58 +02:00
Elias Schneider
0111a58dac fix: add "type" as reserved claim 2025-04-17 20:41:21 +02:00
Elias Schneider
50e4c5c314 chore(translations): update translations via Crowdin (#444) 2025-04-17 20:19:50 +02:00
Kyle Mendell
5a6dfd9e50 fix: profile picture empty for users without first or last name (#449) 2025-04-17 20:19:10 +02:00
Elias Schneider
75fbfee4d8 chore(translations): add Italian 2025-04-17 19:13:47 +02:00
dependabot[bot]
65ee500ef3 chore(deps): bump golang.org/x/net from 0.36.0 to 0.38.0 in /backend in the go_modules group across 1 directory (#450)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-16 18:26:55 -05:00
Elias Schneider
80f108e5d6 release: 0.47.0 2025-04-16 16:32:27 +02:00
Elias Schneider
9b2d622990 tests: adapt JWTs in e2e tests 2025-04-16 16:30:38 +02:00
Elias Schneider
adf74586af fix: define token type as claim for better client compatibility 2025-04-16 15:58:38 +02:00
Kyle Mendell
b45cf68295 feat: disable animations setting toggle (#442)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-15 19:28:10 +00:00
dependabot[bot]
d9dd67c51f chore(deps-dev): bump @sveltejs/kit from 2.16.1 to 2.20.6 in /frontend in the npm_and_yarn group across 1 directory (#443)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-15 20:38:03 +02:00
Grégory Paul
abf17f6211 feat: add qrcode representation of one time link (#424) (#436)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-14 13:16:46 +00:00
Elias Schneider
57cb8f8795 release: 0.46.0 2025-04-13 20:31:09 +02:00
Elias Schneider
fcb18b8c3c chore(translations): update translations via Crowdin (#427)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-13 20:30:43 +02:00
Alessandro (Ale) Segala
796bc7ed34 fix: improve LDAP error handling (#425)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-12 18:38:19 -04:00
Arne Skaar Fismen
72061ba427 feat(onboarding): Added button when you don't have a passkey added. (#426)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-12 02:27:01 +00:00
dependabot[bot]
d04167cada chore(deps-dev): bump vite from 6.2.5 to 6.2.6 in /frontend in the npm_and_yarn group across 1 directory (#433) 2025-04-11 20:07:40 -05:00
Alessandro (Ale) Segala
f83bab9e17 refactor: simplify app_config service and fix race conditions (#423) 2025-04-10 13:41:22 +02:00
Elias Schneider
4ba68938dd fix: ignore profile picture cache after profile picture gets updated 2025-04-09 15:51:58 +02:00
Elias Schneider
658a9ca6dd fix: add missing rollback for LDAP sync 2025-04-09 14:05:53 +02:00
Andreas Schneider
7e5d16be9b feat: implement token introspection (#405)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-09 07:18:03 +00:00
Elias Schneider
8d6c1e5c08 chore(translations): update translations via Crowdin (#420) 2025-04-09 02:09:01 -05:00
Elias Schneider
ce6e27d0ff refactor: rollback db changes with defer everywhere 2025-04-06 23:40:56 +02:00
Elias Schneider
3ebff09d63 chore(translations): update translations via Crowdin (#416)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-06 22:15:05 +02:00
Elias Schneider
ccc18d716f fix: use UUID for temporary file names 2025-04-06 15:11:19 +02:00
Alessandro (Ale) Segala
ec626ee797 fix: use transactions when operations involve multiple database queries (#392)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-06 15:04:08 +02:00
Kyle Mendell
c810fec8c4 docs: update swagger description to use markdown (#418) 2025-04-05 16:07:56 +02:00
Alessandro (Ale) Segala
9e88926283 fix: ensure indexes on audit_logs table (#415)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-04-04 17:05:32 +00:00
dependabot[bot]
731113183e chore(deps-dev): bump vite from 6.2.4 to 6.2.5 in /frontend in the npm_and_yarn group across 1 directory (#417)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-04 16:15:37 +00:00
Elias Schneider
4627f365a2 chore(translations): fix mistakes in source strings 2025-04-04 13:55:15 +02:00
Elias Schneider
1762629596 perf: run async operations in parallel in server load functions 2025-04-04 11:39:13 +02:00
Alessandro (Ale) Segala
2f7646105e fix: ensure file descriptors are closed + other bugs (#413) 2025-04-04 10:04:36 +02:00
Elias Schneider
980780e48b chore(translations): update translations via Crowdin (#414) 2025-04-04 09:06:44 +02:00
Kyle Mendell
b65e693e12 feat: global audit log (#320)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-03 10:11:49 -05:00
Kyle Mendell
734c6813ea fix: create reusable default profile pictures (#406)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-04-03 08:06:56 -05:00
dependabot[bot]
0d31c0ec6c chore(deps-dev): bump vite from 6.2.3 to 6.2.4 in /frontend in the npm_and_yarn group across 1 directory (#410)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-31 14:04:02 -05:00
jose_d
4806c1e09b chore(translations): improve czech translation strings (#408) 2025-03-31 08:22:06 -05:00
Elias Schneider
cf3084cfa8 refactor: remove cors exception from middleware as this is handled by the handler 2025-03-30 22:30:22 +02:00
Kyle Mendell
9881a1df9e feat: modernize ui (#381)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-30 13:19:14 -05:00
Elias Schneider
5dcf69e974 release: 0.45.0 2025-03-30 00:12:19 +01:00
Alessandro (Ale) Segala
519d58d88c fix: use WAL for SQLite by default and set busy_timeout (#388)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 23:12:48 +01:00
Alessandro (Ale) Segala
b3b43a56af refactor: do not include test controller in production builds (#402)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-29 22:11:25 +00:00
Elias Schneider
fc68cf7eb2 chore(translations): add Brazilian Portuguese 2025-03-29 23:03:18 +01:00
Elias Schneider
8ca7873802 chore(translations): update translations via Crowdin (#394)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 22:59:24 +01:00
Elias Schneider
591bf841f5 Merge remote-tracking branch 'origin/main' 2025-03-29 22:56:04 +01:00
Kyle Mendell
8f8884d208 refactor: add swagger title and version info (#399) 2025-03-29 21:55:47 +00:00
Elias Schneider
7e658276f0 fix: ldap users aren't deleted if removed from ldap server 2025-03-29 22:55:44 +01:00
Gutyina Gergő
583a1f8fee chore(deps): install inlang plugins from npm (#401)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-29 22:50:51 +01:00
Rich
b935a4824a ci/cd: migrate backend linter to v2. fixed unit test workflow (#400) 2025-03-28 04:00:55 -05:00
Elias Schneider
cbd1bbdf74 fix: use value receiver for AuditLogData 2025-03-27 22:41:19 +01:00
Alessandro (Ale) Segala
96876a99c5 feat: add support for ECDSA and EdDSA keys (#359)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-27 18:20:39 +01:00
Elias Schneider
5c198c280c refactor: fix code smells 2025-03-27 17:46:10 +01:00
Elias Schneider
c9e0073b63 refactor: fix code smells 2025-03-27 16:48:36 +01:00
Elias Schneider
6fa26c97be ci/cd: run linter only on backend changes 2025-03-27 16:18:15 +01:00
Elias Schneider
6746dbf41e chore(translations): update translations via Crowdin (#386) 2025-03-27 15:15:22 +00:00
Rich
4ac1196d8d ci/cd: add basic static analysis for backend (#389) 2025-03-27 16:13:56 +01:00
Sam
4d049bbe24 docs: update .env.example to reflect the new documentation location (#385) 2025-03-25 21:53:23 +00:00
Elias Schneider
664a1cf8ef release: 0.44.0 2025-03-25 17:09:06 +01:00
Elias Schneider
e6f50191cf fix: stop container if Caddy, the frontend or the backend fails 2025-03-25 16:40:53 +01:00
dependabot[bot]
de9a3cce03 chore(deps-dev): bump vite from 6.2.1 to 6.2.3 in /frontend in the npm_and_yarn group across 1 directory (#384)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-25 09:52:15 -05:00
Alessandro (Ale) Segala
8c963818bb fix: hash the refresh token in the DB (security) (#379) 2025-03-25 15:36:53 +01:00
Alessandro (Ale) Segala
26b2de4f00 refactor: use atomic renames for uploaded files (#372)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-23 20:21:44 +00:00
Kyle Mendell
b8dcda8049 feat: add OIDC refresh_token support (#325)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-23 20:14:26 +00:00
Kyle Mendell
7888d70656 docs: fix api routers for swag documentation (#378) 2025-03-23 19:26:07 +00:00
Elias Schneider
35766af055 chore(translations): add French, Czech and German to language picker 2025-03-23 20:13:58 +01:00
Elias Schneider
c53de25d25 chore(translations): update translations via Crowdin (#375) 2025-03-23 19:09:34 +00:00
Kyle Mendell
cdfe8161d4 fix: skip ldap objects without a valid unique id (#376)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-23 18:30:12 +00:00
dependabot[bot]
e2f74e5687 chore(deps): bump github.com/golang-jwt/jwt/v5 from 5.2.1 to 5.2.2 in /backend in the go_modules group across 1 directory (#374)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-21 17:45:52 -05:00
Elias Schneider
132efd675c chore(translations): update translations via Crowdin (#368) 2025-03-21 21:32:28 +00:00
Elias Schneider
1167454c4f Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-03-21 22:30:40 +01:00
Elias Schneider
af5b2f7913 ci/cd: skip e2e tests if the PR comes from i18n_crowdin 2025-03-21 22:30:37 +01:00
Savely Krasovsky
bc4af846e1 chore(translations): add Russian localization (#371)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-21 21:24:55 +00:00
Elias Schneider
edf1097dd3 ci/cd: fix invalid action configuration 2025-03-21 22:20:05 +01:00
Elias Schneider
eb34535c5a release: 0.43.1 2025-03-20 21:38:02 +01:00
Elias Schneider
3120ebf239 fix: wrong base locale causes crash 2025-03-20 21:36:05 +01:00
Elias Schneider
2fb41937ca ci/cd: ignore e2e tests on Crowdin branch 2025-03-20 20:49:17 +01:00
Elias Schneider
d78a1c6974 release: 0.43.0 2025-03-20 20:47:17 +01:00
Elias Schneider
c578baba95 chore: add language request issue template 2025-03-20 20:38:33 +01:00
Elias Schneider
bb23194e88 chore(translations): remove unused messages 2025-03-20 20:26:43 +01:00
Elias Schneider
31ac56004a refactor: use language code with country for messages 2025-03-20 20:15:26 +01:00
Elias Schneider
d59ec01b33 Update Crowdin configuration file 2025-03-20 20:12:48 +01:00
Elias Schneider
3ee26a2cfb chore: update Crowdin configuration 2025-03-20 20:09:05 +01:00
Elias Schneider
39395c79c3 Update Crowdin configuration file 2025-03-20 20:08:24 +01:00
Jonas Claes
269b5a3c92 feat: add support for translations (#349)
Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-20 18:57:41 +00:00
Kyle Mendell
041c565dc1 feat(passkeys): name new passkeys based on agguids (#332)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-20 15:35:08 +00:00
Elias Schneider
e486dbd771 release: 0.42.1 2025-03-18 23:03:50 +01:00
Elias Schneider
f7e36a422e fix: kid not added to JWTs 2025-03-18 23:03:34 +01:00
Elias Schneider
f74c7bf95d release: 0.42.0 2025-03-18 21:11:19 +01:00
Alessandro (Ale) Segala
a7c9741802 feat: store keys as JWK on disk (#339)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-03-18 21:08:33 +01:00
Elias Schneider
e9b2d981b7 release: 0.41.0 2025-03-18 21:04:53 +01:00
Kyle Mendell
8f146188d5 feat(profile-picture): allow reset of profile picture (#355)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-18 19:59:31 +00:00
Viktor Szépe
a0f93bda49 chor: correct misspellings (#352) 2025-03-18 12:54:39 +01:00
Savely Krasovsky
0423d354f5 fix: own avatar not loading (#351) 2025-03-18 12:02:59 +01:00
Elias Schneider
9245851126 release: 0.40.1 2025-03-16 18:02:49 +01:00
Alexander Lehmann
39b7f6678c fix: emails are considered as medium spam by rspamd (#337) 2025-03-16 17:46:45 +01:00
Elias Schneider
e45d9e970d fix: caching for own profile picture 2025-03-16 17:45:30 +01:00
Elias Schneider
8ead0be8cd fix: API keys not working if sqlite is used 2025-03-16 14:28:44 +01:00
Elias Schneider
9f28503d6c fix: remove custom claim key restrictions 2025-03-16 14:11:33 +01:00
Elias Schneider
26e05947fe ci/cd: add separate worfklow for unit tests 2025-03-16 13:08:56 +01:00
Alessandro (Ale) Segala
348192b9d7 fix: Fixes and performance improvements in utils package (#331) 2025-03-14 19:21:24 -05:00
Kyle Mendell
b483e2e92f fix: email logo icon displaying too big (#336) 2025-03-14 13:38:27 -05:00
Elias Schneider
42f55e6e54 release: 0.40.0 2025-03-13 20:49:48 +01:00
Elias Schneider
a4bfd08a0f chore: automatically detect release type in release script 2025-03-13 20:49:33 +01:00
Alessandro (Ale) Segala
7b654c6bd1 feat: allow setting path where keys are stored (#327) 2025-03-13 17:01:15 +01:00
Elias Schneider
8c1c04db1d Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-03-13 14:18:54 +01:00
Elias Schneider
ec4b41a1d2 fix(docker): missing write permissions on scripts 2025-03-13 14:18:48 +01:00
dependabot[bot]
d27a121985 chore(deps): bump @babel/runtime from 7.26.7 to 7.26.10 in /frontend in the npm_and_yarn group across 1 directory (#328)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 14:15:49 +01:00
dependabot[bot]
d8952c0d62 chore(deps): bump golang.org/x/net from 0.34.0 to 0.36.0 in /backend in the go_modules group across 1 directory (#326)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-13 14:06:43 +01:00
Nebula
f65997e85b chore: add Dev Container (#313) 2025-03-11 17:24:41 -05:00
Elias Schneider
90f8068053 release: 0.39.0 2025-03-11 20:59:15 +01:00
Elias Schneider
9ef2ddf796 fix: alternative login method link on mobile 2025-03-11 20:58:30 +01:00
Elias Schneider
d1b9f3a44e refactor: adapt api key list to new sort behavior 2025-03-11 20:22:56 +01:00
Kyle Mendell
62915d863a feat: api key authentication (#291)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-11 19:16:42 +00:00
Elias Schneider
74ba8390f4 release: 0.38.0 2025-03-10 20:52:35 +01:00
Elias Schneider
31198feec2 feat: add env variable to disable update check 2025-03-10 20:48:57 +01:00
Elias Schneider
e5ec264bfd fix: redirection not correctly if signing in with email code 2025-03-10 20:36:52 +01:00
Kot C
c822192124 fix: typo in account settings (#307) 2025-03-10 13:35:46 +00:00
Elias Schneider
f2d61e964c release: 0.37.0 2025-03-10 14:09:30 +01:00
dependabot[bot]
f1256322b6 chore(deps): bump the npm_and_yarn group across 1 directory with 3 updates (#306)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-10 14:06:13 +01:00
Elias Schneider
7885ae011c tests: fix user group assignment test 2025-03-10 14:05:51 +01:00
Elias Schneider
6a8dd84ca9 fix: add back setup page 2025-03-10 13:00:08 +01:00
Jonas
eb1426ed26 feat(account): add ability to sign in with login code (#271)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-10 12:45:45 +01:00
Elias Schneider
a9713cf6a1 feat: increase default item count per page 2025-03-10 12:39:42 +01:00
Elias Schneider
8e344f1151 fix: make sorting consistent around tables 2025-03-10 12:37:16 +01:00
Elias Schneider
04efc36115 fix: add timeout to update check 2025-03-10 09:41:58 +01:00
Elias Schneider
2ee0bad2c0 docs: add Discord contact link to issue template 2025-03-07 14:25:19 +01:00
Elias Schneider
d0da532240 refactor: fix type errors 2025-03-07 13:56:24 +01:00
Elias Schneider
8d55c7c393 release: 0.36.0 2025-03-06 22:25:25 +01:00
Kyle Mendell
0f14a93e1d feat: display groups on the account page (#296)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 22:25:03 +01:00
Elias Schneider
37b24bed91 ci/cd: remove PR docker build action 2025-03-06 22:24:00 +01:00
Elias Schneider
66090f36a8 ci/cd: use github.repository variable intead of hardcoding the repository name 2025-03-06 19:13:44 +01:00
Kyle Mendell
ff34e3b925 fix: default sorting on tables (#299)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 17:42:31 +01:00
Savely Krasovsky
91f254c7bb feat: enable sd_notify support (#277) 2025-03-06 17:42:12 +01:00
Kyle Mendell
85db96b0ef ci/cd: add pr docker build (#293)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-06 16:29:33 +01:00
Elias Schneider
12d60fea23 release: 0.35.6 2025-03-03 16:49:55 +01:00
Elias Schneider
2d733fc79f fix: support LOGIN authentication method for SMTP (#292) 2025-03-03 16:48:38 +01:00
Elias Schneider
a421d01e0c release: 0.35.5 2025-03-03 16:48:07 +01:00
Elias Schneider
1026ee4f5b fix: profile picture orientation if image is rotated with EXIF 2025-03-03 09:06:52 +01:00
Elias Schneider
cddfe8fa4c release: 0.35.4 2025-03-01 20:42:53 +01:00
Jonas
ef25f6b6b8 fix: profile picture of other user can't be updated (#273) 2025-03-01 20:42:29 +01:00
Elias Schneider
1652cc65f3 fix: support POST for OIDC userinfo endpoint 2025-03-01 20:42:00 +01:00
Elias Schneider
4bafee4f58 fix: add groups scope and claim to well known endpoint 2025-03-01 20:41:30 +01:00
Elias Schneider
e46471cc2d release: 0.35.3 2025-02-25 20:34:37 +01:00
Elias Schneider
fde951b543 fix(ldap): sync error if LDAP user collides with an existing user 2025-02-25 20:34:13 +01:00
Kyle Mendell
01a9de0b04 fix: add option to manually select SMTP TLS method (#268)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-25 19:10:20 +01:00
Elias Schneider
a1131bca9a release: 0.35.2 2025-02-24 09:40:48 +01:00
Elias Schneider
9a167d4076 fix: delete profile picture if user gets deleted 2025-02-24 09:40:14 +01:00
Elias Schneider
887c5e462a fix: updating profile picture of other user updates own profile picture 2025-02-24 09:35:44 +01:00
Elias Schneider
20eba1378e release: 0.35.1 2025-02-22 14:59:43 +01:00
Elias Schneider
a6ae7ae287 fix: add validation that PUBLIC_APP_URL can't contain a path 2025-02-22 14:59:10 +01:00
Elias Schneider
840a672fc3 fix: binary profile picture can't be imported from LDAP 2025-02-22 14:51:21 +01:00
Elias Schneider
7446f853fc release: 0.35.0 2025-02-19 14:29:24 +01:00
Elias Schneider
652ee6ad5d feat: add ability to upload a profile picture (#244) 2025-02-19 14:28:45 +01:00
Elias Schneider
dca9e7a11a fix: emails do not get rendered correctly in Gmail 2025-02-19 13:54:36 +01:00
Elias Schneider
816c198a42 fix: app config strings starting with a number are parsed incorrectly 2025-02-18 21:36:08 +01:00
Elias Schneider
339837bec4 release: 0.34.0 2025-02-16 18:29:18 +01:00
Kyle Mendell
39b46e99a9 feat: add LDAP group membership attribute (#236)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-16 18:27:07 +01:00
Elias Schneider
dc9e64de3d release: 0.33.0 2025-02-14 17:10:14 +01:00
Elias Schneider
6207e10279 Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-02-14 17:09:39 +01:00
Elias Schneider
7550333fe2 feat: add end session endpoint (#232) 2025-02-14 17:09:27 +01:00
Elias Schneider
3de1301fa8 fix: layout of OIDC client details page on mobile 2025-02-14 16:03:17 +01:00
Elias Schneider
c3980d3d28 fix: alignment of OIDC client details 2025-02-14 15:53:30 +01:00
Elias Schneider
4d0fff821e fix: show "Sync Now" and "Test Email" button even if UI config is disabled 2025-02-14 13:32:01 +01:00
Elias Schneider
2e66211b7f release: 0.32.0 2025-02-13 21:02:20 +01:00
Giovanni
2071d002fc feat: add ability to set custom Geolite DB URL 2025-02-13 21:01:43 +01:00
Elias Schneider
0d071694cd release: 0.31.0 2025-02-12 16:29:43 +01:00
Kyle Mendell
39e403d00f feat: add warning for only having one passkey configured (#220)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-12 16:29:08 +01:00
Elias Schneider
4e858420e9 feat: add ability to override the UI configuration with environment variables 2025-02-12 14:20:52 +01:00
Kyle Mendell
2d78349b38 fix: user linking in ldap group sync (#222) 2025-02-11 17:25:00 +01:00
Kyle Mendell
9ed2adb0f8 feat: display source in user and group table (#225)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-11 17:24:10 +01:00
Elias Schneider
43790dc1be ci/cd: downgrade ubuntu version of Docker build action runner 2025-02-09 15:16:13 +01:00
Elias Schneider
7fbc356d8d ci/cd: remove Docker Hub registry 2025-02-09 15:11:10 +01:00
Elias Schneider
9b77e8b7c1 release: 0.30.0 2025-02-08 18:19:11 +01:00
Jonas Claes
bea115866f feat: update host configuration to allow external access (#218) 2025-02-08 18:17:57 +01:00
Kyle Mendell
626f87d592 feat: add custom ldap search filters (#216) 2025-02-08 18:16:57 +01:00
Elias Schneider
0751540d7d chore: remove docs from repository 2025-02-06 17:22:43 +01:00
Elias Schneider
7c04bda5b7 docs: improve mobile layout of landing page 2025-02-06 16:57:16 +01:00
Elias Schneider
98add37390 docs: add docs root path redirection 2025-02-06 16:50:38 +01:00
Elias Schneider
3dda2e16e9 docs: improve landing page 2025-02-06 16:42:29 +01:00
Kyle Mendell
3a6fce5c4b docs: add landing page (#203)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-02-06 16:41:44 +01:00
Elias Schneider
07ee087c3d Merge branch 'main' of https://github.com/pocket-id/pocket-id 2025-02-06 12:12:55 +01:00
Elias Schneider
d66cf70d50 ci/cd: add missing permissions to "Build and Push Docker Image" 2025-02-06 12:12:49 +01:00
RobinMicek
fb8cc0bb22 docs: fix freshrss callback url (#212) 2025-02-06 07:37:14 +01:00
Elias Schneider
0bae7e4f53 chore: fix old docker image references 2025-02-05 18:46:31 +01:00
604 changed files with 31290 additions and 26829 deletions

View File

@@ -0,0 +1,21 @@
{
"name": "pocket-id",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": {
"ghcr.io/devcontainers/features/go:1": {}
},
"customizations": {
"vscode": {
"extensions": [
"golang.go",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}
},
"containerEnv": {
"HOST": "0.0.0.0"
},
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
}

View File

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

View File

@@ -1,5 +1,5 @@
# See the README for more information: https://github.com/pocket-id/pocket-id?tab=readme-ov-file#environment-variables
PUBLIC_APP_URL=http://localhost
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
APP_URL=https://your-pocket-id-domain.com
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000

2
.github/FUNDING.yml vendored
View File

@@ -1,2 +1,2 @@
# These are supported funding model platforms
github: stonith404
github: [stonith404, kmendell]

View File

@@ -49,7 +49,7 @@ body:
required: false
attributes:
label: "Log Output"
description: "Output of log files when the issue occured to help us diagnose the issue."
description: "Output of log files when the issue occurred to help us diagnose the issue."
- type: markdown
attributes:
value: |

View File

@@ -1 +1,5 @@
blank_issues_enabled: false
blank_issues_enabled: false
contact_links:
- name: 💬 Discord
url: https://discord.gg/8wudU9KaxM
about: For help and chatting with the community

View File

@@ -0,0 +1,20 @@
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]
body:
- type: input
id: language-name-native
attributes:
label: "🌐 Language Name (native)"
placeholder: "Schweizerdeutsch"
validations:
required: true
- type: input
id: language-code
attributes:
label: "🌐 ISO 639-1 Language Code"
description: "You can find your language code [here](https://www.andiamo.co.uk/resources/iso-language-codes/)."
placeholder: "de-CH"
validations:
required: true

12
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,12 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for more information:
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
# https://containers.dev/guide/dependabot
version: 2
updates:
- package-ecosystem: "devcontainers"
directory: "/"
schedule:
interval: weekly

21
.github/svelte-check-matcher.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
"problemMatcher": [
{
"owner": "svelte-check",
"pattern": [
{
"regexp": "^([^\\s].*):(\\d+):(\\d+)$",
"file": 1,
"line": 2,
"column": 3
},
{
"regexp": "^\\s*(Error|Warning):\\s*(.*)\\s+\\((?:ts|js|svelte)\\)$",
"severity": 1,
"message": 2,
"loop": false
}
]
}
]
}

40
.github/workflows/backend-linter.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
name: Run Backend Linter
on:
push:
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
- "backend/**"
permissions:
# Required: allow read access to the content for analysis.
contents: read
# Optional: allow read access to pull request. Use with `only-new-issues` option.
pull-requests: read
# Optional: allow write access to checks to allow the action to annotate code in the PR.
checks: write
jobs:
golangci-lint:
name: Run Golangci-lint
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: backend/go.mod
- name: Run Golangci-lint
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
with:
version: v2.0.2
args: --build-tags=exclude_frontend
working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -1,53 +0,0 @@
name: Build and Push Docker Image
on:
release:
types: [published]
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: checkout code
uses: actions/checkout@v3
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
ghcr.io/${{ github.repository }}
${{ github.repository }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_REGISTRY_USERNAME }}
password: ${{ secrets.DOCKER_REGISTRY_PASSWORD }}
- name: 'Login to GitHub Container Registry'
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Build and push
uses: docker/build-push-action@v4
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max

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

@@ -0,0 +1,82 @@
name: Build Next Image
on:
push:
branches:
- main
concurrency:
group: build-next-image
cancel-in-progress: true
jobs:
build-next:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh --docker-only
- name: Build and push container image
id: build-push-image
uses: docker/build-push-action@v6
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: Dockerfile-prebuilt
- name: 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

View File

@@ -1,51 +0,0 @@
name: Deploy Docs
on:
push:
branches:
- main
paths:
- "docs/**"
jobs:
build:
name: Build Docusaurus
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: "npm"
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: npm install
working-directory: ./docs
- name: Build website
run: npm run build
working-directory: ./docs
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@v3
with:
path: docs/build
deploy:
name: Deploy to GitHub Pages
needs: build
permissions:
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -15,139 +15,223 @@ on:
jobs:
build:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
timeout-minutes: 20
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and export
uses: docker/build-push-action@v6
with:
tags: pocket-id/pocket-id:test
push: false
load: false
tags: pocket-id:test
outputs: type=docker,dest=/tmp/docker-image.tar
build-args: BUILD_TAGS=e2etest
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Upload Docker image artifact
uses: actions/upload-artifact@v4
with:
name: docker-image
path: /tmp/docker-image.tar
retention-days: 1
test-sqlite:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
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: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker Image
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
- 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
- name: Install Playwright Browsers
working-directory: ./frontend
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB
- name: Run Docker Container with Sqlite DB and LDAP
working-directory: ./tests/setup
run: |
docker run -d --name pocket-id-sqlite \
-p 80:80 \
-e APP_ENV=test \
pocket-id/pocket-id:test
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
working-directory: ./tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-sqlite
path: frontend/tests/.report
path: tests/.report
include-hidden-files: true
retention-days: 15
- name: Upload Backend Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-sqlite
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: lts/*
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
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install frontend dependencies
working-directory: ./frontend
- name: Install test dependencies
working-directory: ./tests
run: npm ci
- name: Install Playwright Browsers
working-directory: ./frontend
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Create Docker network
run: docker network create pocket-id-network
- name: Start Postgres DB
- name: Run Docker Container with Postgres DB and LDAP
working-directory: ./tests/setup
run: |
docker run -d --name pocket-id-db \
--network pocket-id-network \
-e POSTGRES_USER=postgres \
-e POSTGRES_PASSWORD=postgres \
-e POSTGRES_DB=pocket-id \
-p 5432:5432 \
postgres:17
- name: Wait for Postgres to start
run: |
for i in {1..10}; do
if docker exec pocket-id-db pg_isready -U postgres; then
echo "Postgres is ready"
break
fi
echo "Waiting for Postgres..."
sleep 2
done
- name: Run Docker Container with Postgres DB
run: |
docker run -d --name pocket-id-postgres \
--network pocket-id-network \
-p 80:80 \
-e APP_ENV=test \
-e DB_PROVIDER=postgres \
-e POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
stonith404/pocket-id:test
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./frontend
working-directory: ./tests
run: npx playwright test
- uses: actions/upload-artifact@v4
if: always()
- 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
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

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

@@ -0,0 +1,106 @@
name: Release
on:
push:
tags:
- "v*.*.*"
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: write
packages: write
attestations: write
id-token: write
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Set DOCKER_IMAGE_NAME
run: |
# Lowercase REPO_OWNER which is required for containers
REPO_OWNER=${{ github.repository_owner }}
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{github.repository_owner}}
password: ${{secrets.GITHUB_TOKEN}}
- name: Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_NAME }}
tags: |
type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies
working-directory: frontend
run: npm ci
- name: Build frontend
working-directory: frontend
run: npm run build
- name: Build binaries
run: sh scripts/development/build-binaries.sh
- name: Build and push container image
uses: docker/build-push-action@v6
id: container-build-push
with:
context: .
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
file: Dockerfile-prebuilt
- name: 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: Upload binaries to release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${{ github.ref_name }} backend/.bin/*
publish-release:
runs-on: ubuntu-latest
needs: [build]
permissions:
contents: write
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Checkout code
uses: actions/checkout@v3
- name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false

59
.github/workflows/svelte-check.yml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: Svelte Check
on:
push:
branches: [main]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/tsconfig.json"
- "frontend/svelte.config.js"
pull_request:
branches: [main]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"
- "frontend/package.json"
- "frontend/package-lock.json"
- "frontend/tsconfig.json"
- "frontend/svelte.config.js"
workflow_dispatch:
jobs:
type-check:
name: Run Svelte Check
# Don't run on dependabot branches
if: github.actor != 'dependabot[bot]'
runs-on: ubuntu-latest
permissions:
contents: read
checks: write
pull-requests: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Install dependencies
working-directory: frontend
run: npm ci
- name: Build Pocket ID Frontend
working-directory: frontend
run: npm run 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

38
.github/workflows/unit-tests.yml vendored Normal file
View File

@@ -0,0 +1,38 @@
name: Unit Tests
on:
push:
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main]
paths:
- "backend/**"
jobs:
test-backend:
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: "backend/go.mod"
cache-dependency-path: "backend/go.sum"
- name: Install dependencies
working-directory: backend
run: |
go get ./...
- name: Run backend unit tests
working-directory: backend
run: |
set -e -o pipefail
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
- uses: actions/upload-artifact@v4
if: always()
with:
name: backend-unit-tests
path: /tmp/TestResults.log
retention-days: 15

39
.github/workflows/update-aaguids.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: Update AAGUIDs
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch: # Allows manual triggering of the workflow
permissions:
contents: write
pull-requests: write
jobs:
update-aaguids:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch JSON data
run: |
curl -o data.json https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json
- name: Process JSON data
run: |
mkdir -p backend/resources
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
rm data.json
- name: Create Pull Request
uses: peter-evans/create-pull-request@v7
with:
commit-message: "chore: update AAGUIDs"
title: "chore: update AAGUIDs"
body: |
This PR updates the AAGUIDs file with the latest data from the [passkey-aaguids](https://github.com/pocket-id/passkey-aaguids) repository.
branch: update-aaguids
base: main
delete-branch: true

20
.gitignore vendored
View File

@@ -9,6 +9,8 @@ node_modules
/frontend/.svelte-kit
/frontend/build
/backend/bin
pocket-id
/tests/test-results/*.json
# OS
.DS_Store
@@ -17,7 +19,7 @@ Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.development-example
!.env.test
# Vite
@@ -30,18 +32,15 @@ vite.config.ts.timestamp-*
*.dll
*.so
*.dylib
/backend/.bin
/pocket-id
# Application specific
data
/frontend/tests/.auth
/frontend/tests/.report
pocket-id-backend
/tests/.auth
/tests/.report
/backend/GeoLite2-City.mmdb
# Generated files
docs/build
docs/.docusaurus
docs/.cache-loader
/backend/frontend/dist
# Misc
.DS_Store
@@ -53,3 +52,6 @@ docs/.cache-loader
npm-debug.log*
yarn-debug.log*
yarn-error.log*
#Debug
backend/cmd/__debug_*

View File

@@ -1 +1 @@
0.29.0
1.4.1

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"golang.go",
"svelte.svelte-vscode",
"bradlc.vscode-tailwindcss",
"esbenp.prettier-vscode"
]
}

42
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Backend",
"type": "go",
"request": "launch",
"envFile": "${workspaceFolder}/backend/cmd/.env",
"env": {
"APP_ENV": "development"
},
"mode": "debug",
"program": "${workspaceFolder}/backend/cmd/main.go",
},
{
"name": "Frontend",
"type": "node",
"request": "launch",
"envFile": "${workspaceFolder}/frontend/.env",
"cwd": "${workspaceFolder}/frontend",
"runtimeExecutable": "npm",
"runtimeArgs": [
"run",
"dev"
]
}
],
"compounds": [
{
"name": "Development",
"configurations": [
"Backend",
"Frontend"
],
"presentation": {
"hidden": false,
"group": "",
"order": 1
}
}
],
}

4
.vscode/settings.json vendored Normal file
View File

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

View File

@@ -1,3 +1,520 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.0...v) (2025-06-22)
### Bug Fixes
* app not starting if UI config is disabled and Postgres is used ([7d36bda](https://github.com/pocket-id/pocket-id/commit/7d36bda769e25497dec6b76206a4f7e151b0bd72))
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.1...v) (2025-06-19)
### Features
* allow setting unix socket mode ([#661](https://github.com/pocket-id/pocket-id/issues/661)) ([7677a3d](https://github.com/pocket-id/pocket-id/commit/7677a3de2c923c11a58bc8c4d1b2121d403a1504))
* auto-focus on the login buttons ([#647](https://github.com/pocket-id/pocket-id/issues/647)) ([d679530](https://github.com/pocket-id/pocket-id/commit/d6795300b158b85dd9feadd561b6ecd891f5db0d))
* configurable local ipv6 ranges for audit log ([#657](https://github.com/pocket-id/pocket-id/issues/657)) ([d548523](https://github.com/pocket-id/pocket-id/commit/d5485238b8fd4cc566af00eae2b17d69a119f991))
* location filter for global audit log ([#662](https://github.com/pocket-id/pocket-id/issues/662)) ([ac5a121](https://github.com/pocket-id/pocket-id/commit/ac5a121f664b8127d0faf30c0f93432f30e7f33a))
* ui accent colors ([#643](https://github.com/pocket-id/pocket-id/issues/643)) ([883877a](https://github.com/pocket-id/pocket-id/commit/883877adec6fc3e65bd5a705499449959b894fb5))
* use icon instead of text on application image update hover state ([215531d](https://github.com/pocket-id/pocket-id/commit/215531d65c6683609b0b4a5505fdb72696fdb93e))
### Bug Fixes
* allow images with uppercase file extension ([1bcb50e](https://github.com/pocket-id/pocket-id/commit/1bcb50edc335886dd722a4c69960c48cc3cd1687))
* center oidc client images if they are smaller than the box ([946c534](https://github.com/pocket-id/pocket-id/commit/946c534b0877a074a6b658060f9af27e4061397c))
* explicitly cache images to prevent unexpected behavior ([2e5d268](https://github.com/pocket-id/pocket-id/commit/2e5d2687982186c12e530492292d49895cb6043a))
* reduce duration of animations on login and signin page ([#648](https://github.com/pocket-id/pocket-id/issues/648)) ([d770448](https://github.com/pocket-id/pocket-id/commit/d77044882d5a41da22df1c0099c1eb1f20bcbc5b))
* use inline style for dynamic background image URL instead of Tailwind class ([bef77ac](https://github.com/pocket-id/pocket-id/commit/bef77ac8dca2b98b6732677aaafbc28f79d00487))
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.0...v) (2025-06-09)
### Bug Fixes
* change timestamp of `client_credentials.sql` migration ([2935236](https://github.com/pocket-id/pocket-id/commit/2935236acee9c78c2fe6787ec8b5f53ae0eca047))
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
### Features
* add API endpoint for user authorized clients ([d217083](https://github.com/pocket-id/pocket-id/commit/d217083059120171d5c555b09eefe6ba3c8a8d42))
* add unix socket support ([#615](https://github.com/pocket-id/pocket-id/issues/615)) ([035b2c0](https://github.com/pocket-id/pocket-id/commit/035b2c022bfd2b98f13355ec7a126e0f1ab3ebd8))
* allow introspection and device code endpoints to use Federated Client Credentials ([#640](https://github.com/pocket-id/pocket-id/issues/640)) ([b62b61f](https://github.com/pocket-id/pocket-id/commit/b62b61fb017dba31a6fc612c138bebf370d3956c))
* JWT bearer assertions for client authentication ([#566](https://github.com/pocket-id/pocket-id/issues/566)) ([05bfe00](https://github.com/pocket-id/pocket-id/commit/05bfe0092450c9bc26d03c6a54c21050eef8f63a))
* new color theme for the UI ([97f7326](https://github.com/pocket-id/pocket-id/commit/97f7326da40265a954340d519661969530f097a0))
* oidc client data preview ([#624](https://github.com/pocket-id/pocket-id/issues/624)) ([c111b79](https://github.com/pocket-id/pocket-id/commit/c111b7914731a3cafeaa55102b515f84a1ad74dc))
### Bug Fixes
* don't load app config and user on every route change ([bdcef60](https://github.com/pocket-id/pocket-id/commit/bdcef60cab6a61e1717661e918c42e3650d23fee))
* misleading text for disable animations option ([657a51f](https://github.com/pocket-id/pocket-id/commit/657a51f7ed8a77e8a937971032091058aacfded6))
* OIDC client image can't be deleted ([61b62d4](https://github.com/pocket-id/pocket-id/commit/61b62d461200c1359a16c92c9c62530362a4785c))
* UI config overridden by env variables don't apply on first start ([5e9096e](https://github.com/pocket-id/pocket-id/commit/5e9096e328741ba2a0e03835927fe62e6aea2a89))
* use full width for audit log filters ([575b2f7](https://github.com/pocket-id/pocket-id/commit/575b2f71e9f1ff9c4f6fd411b136676c213b7201))
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)
### Features
* auto detect callback url ([#583](https://github.com/pocket-id/pocket-id/issues/583)) ([20d3f78](https://github.com/pocket-id/pocket-id/commit/20d3f780a2a431d0a48cece0f0764b6e4d53c1b9))
### Bug Fixes
* allow users to update their locale even when own account update disabled ([6c00aaa](https://github.com/pocket-id/pocket-id/commit/6c00aaa3efa75c76d340718698a0f4556e8de268))
* clear default app config variables from database ([decf8ec](https://github.com/pocket-id/pocket-id/commit/decf8ec70b5f6a69fe201d6e4ad60ee62e374ad0))
* don't use TOFU for logout callback URLs ([#588](https://github.com/pocket-id/pocket-id/issues/588)) ([256f74d](https://github.com/pocket-id/pocket-id/commit/256f74d0a348a835107fd5b17b9d57b1e845029e))
* fallback to primary language if no translation available for specific country ([2440379](https://github.com/pocket-id/pocket-id/commit/2440379cd11b4a6da7c52b122ba8f49d7c72ce1d))
* improve spacing on auth screens ([04fcf11](https://github.com/pocket-id/pocket-id/commit/04fcf1110e97b42dc5f0c20e169c569075d1e797))
* page scrolls up on form submisssion ([31ad904](https://github.com/pocket-id/pocket-id/commit/31ad904367e53dd47a15abcce5402dfe84828a14))
* run jobs at interval instead of specific time ([#585](https://github.com/pocket-id/pocket-id/issues/585)) ([6d6dc66](https://github.com/pocket-id/pocket-id/commit/6d6dc6646a39921a604b6c825d3e7e76af6c693b))
* show LAN for auditlog location for internal networks ([b874681](https://github.com/pocket-id/pocket-id/commit/b8746818240fde052e6f3b5db5c3355d7bbfcbda))
* small fixes in analytics_job ([#582](https://github.com/pocket-id/pocket-id/issues/582)) ([3d402fc](https://github.com/pocket-id/pocket-id/commit/3d402fc0ca30626c95b8f7accc274b9f2ab228b9))
* whitelist authorization header for CORS ([b9489b5](https://github.com/pocket-id/pocket-id/commit/b9489b5e9a32a2a3f54d48705e731a7bcf188d20))
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
### Features
* add daily heartbeat request for counting Pocket ID instances ([#578](https://github.com/pocket-id/pocket-id/issues/578)) ([e0ec607](https://github.com/pocket-id/pocket-id/commit/e0ec60719883c0230f1a16611b943d6f6f637157))
* require user verification for passkey sign in ([68e4b67](https://github.com/pocket-id/pocket-id/commit/68e4b67bd212e31ecc20277bfd293c94bf7f3642))
* show allowed group count on oidc client list ([#567](https://github.com/pocket-id/pocket-id/issues/567)) ([38d7ee4](https://github.com/pocket-id/pocket-id/commit/38d7ee4432e0dacc2cfbabad4bfd9336b8b84079))
### Bug Fixes
* run user group count inside a transaction ([f03b80f](https://github.com/pocket-id/pocket-id/commit/f03b80f9d7f2529d8cef23ca6a742a914a4ec883))
* use ldapAttributeUserUsername for finding group members ([#565](https://github.com/pocket-id/pocket-id/issues/565)) ([f66e8e8](https://github.com/pocket-id/pocket-id/commit/f66e8e8b4478c66ed1ada9168a272b33dbf494d0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.53.0...v) (2025-05-24)
### ⚠ BREAKING CHANGES
* serve the static frontend trough the backend (#520)
* remove old DB env variables, and jwk migrations logic (#529)
### Features
* improve buttons styling ([c37386f](https://github.com/pocket-id/pocket-id/commit/c37386f8b2f2c64bd9e7c437879a2217846852b5))
### Bug Fixes
* add back month and year selection for date picker ([6c35570](https://github.com/pocket-id/pocket-id/commit/6c35570e78813ca6af1bae6a0374d7483bff9824))
* animation speed set to max of 300ms ([c726c16](https://github.com/pocket-id/pocket-id/commit/c726c1621b8bd88b20cb05263f6d10888f0af8e2))
* authorize page doesn't load ([c3a03db](https://github.com/pocket-id/pocket-id/commit/c3a03db8b0f87cddc927481cfad2ccc391f98869))
* custom logo not correctly loaded if UI configuration is disabled ([bf710ae](https://github.com/pocket-id/pocket-id/commit/bf710aec5625c9dcb43c83d920318a036a135bae))
* ldap tests ([4dc0b2f](https://github.com/pocket-id/pocket-id/commit/4dc0b2f37f9a57ba1c7ea084dc2a713f283d1b14))
* remove curly bracket from user group URL ([5fa15f6](https://github.com/pocket-id/pocket-id/commit/5fa15f60984a8f2a02f15900860c3a3097032e1b))
* remove nested button in user group list ([f57c8d3](https://github.com/pocket-id/pocket-id/commit/f57c8d347e127027378aad8831a8e4dfebfef060))
* show correct app name on sign out page ([131f470](https://github.com/pocket-id/pocket-id/commit/131f470757044fddd0989a76e9dc9e310f19819c))
* trim whitespaces from string inputs ([059073d](https://github.com/pocket-id/pocket-id/commit/059073d4c24e34c142dddd4c150c384779fb51a9))
* use pointer cursor for menu items ([f820fc8](https://github.com/pocket-id/pocket-id/commit/f820fc830161499edb0da2df334e4e473d5825ae))
* use same color as title for description in alert ([e19b33f](https://github.com/pocket-id/pocket-id/commit/e19b33fc2e2b9dd149da1f9351aca2e839ffae04))
### Code Refactoring
* remove old DB env variables, and jwk migrations logic ([#529](https://github.com/pocket-id/pocket-id/issues/529)) ([f115425](https://github.com/pocket-id/pocket-id/commit/f1154257c5a9ac5c95d81343a31c02251631b203))
* serve the static frontend trough the backend ([#520](https://github.com/pocket-id/pocket-id/issues/520)) ([f8a7467](https://github.com/pocket-id/pocket-id/commit/f8a7467ec0e939f90d19211a0a0efc5e17a58127))
## [](https://github.com/pocket-id/pocket-id/compare/v0.52.0...v) (2025-05-08)
### Features
* add support for `TZ` environment variable ([5e2e947](https://github.com/pocket-id/pocket-id/commit/5e2e947fe09fa881a7bbc70133a243a4baf30e90))
### Bug Fixes
* handle CORS correctly for endpoints that SPAs need ([#513](https://github.com/pocket-id/pocket-id/issues/513)) ([63a0c08](https://github.com/pocket-id/pocket-id/commit/63a0c08696938e1cefd12018f4bd38aa1808996a))
## [](https://github.com/pocket-id/pocket-id/compare/v0.51.1...v) (2025-05-06)
### Features
* add healthz endpoint ([#494](https://github.com/pocket-id/pocket-id/issues/494)) ([3c87e4e](https://github.com/pocket-id/pocket-id/commit/3c87e4ec1468c314ac7f8fe831e97b5eead88112))
* OpenTelemetry tracing and metrics ([#262](https://github.com/pocket-id/pocket-id/issues/262)) ([#495](https://github.com/pocket-id/pocket-id/issues/495)) ([6f54ee5](https://github.com/pocket-id/pocket-id/commit/6f54ee5d668d7a26911db10f2402daf6a1f75f68))
### Bug Fixes
* correctly set script permissions inside Docker container ([c55fef0](https://github.com/pocket-id/pocket-id/commit/c55fef057cdcec867af91b29968541983cd80ec0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.51.0...v) (2025-05-03)
### Bug Fixes
* allow LDAP users to update their locale ([0b9cbf4](https://github.com/pocket-id/pocket-id/commit/0b9cbf47e36a332cfd854aa92e761264fb3e4795))
* last name still showing as required on account form ([#492](https://github.com/pocket-id/pocket-id/issues/492)) ([cf3fe0b](https://github.com/pocket-id/pocket-id/commit/cf3fe0be84f6365f5d4eb08c1b47905962a48a0d))
* non admin users weren't able to call the end session endpoint ([6bd6cef](https://github.com/pocket-id/pocket-id/commit/6bd6cefaa6dc571a319a6a1c2b2facc2404eadd3))
## [](https://github.com/pocket-id/pocket-id/compare/v0.50.0...v) (2025-04-28)
### Features
* new login code card position for mobile devices ([#452](https://github.com/pocket-id/pocket-id/issues/452)) ([02cacba](https://github.com/pocket-id/pocket-id/commit/02cacba5c5524481684cb0e1790811df113a9481))
### Bug Fixes
* do not require PKCE for public clients ([ce24372](https://github.com/pocket-id/pocket-id/commit/ce24372c571cc3b277095dc6a4107663d64f45b3))
* hide global audit log switch for non admin users ([1efd1d1](https://github.com/pocket-id/pocket-id/commit/1efd1d182dbb6190d3c7e27034426c9e48781b4a))
* return correct error message if user isn't authorized ([86d2b5f](https://github.com/pocket-id/pocket-id/commit/86d2b5f59f26cb944017826cbd8df915cdc986f1))
* updating scopes of an authorized client fails with Postgres ([0a24ab8](https://github.com/pocket-id/pocket-id/commit/0a24ab80010eb5a15d99915802c6698274a5c57c))
## [](https://github.com/pocket-id/pocket-id/compare/v0.49.0...v) (2025-04-27)
### Features
* device authorization endpoint ([#270](https://github.com/pocket-id/pocket-id/issues/270)) ([22f7d64](https://github.com/pocket-id/pocket-id/commit/22f7d64bf08a5a1ecbe5eee0052453b730f5c360))
* make family name optional ([#476](https://github.com/pocket-id/pocket-id/issues/476)) ([630327c](https://github.com/pocket-id/pocket-id/commit/630327c979de2f931b9d1f0ba0b4a4de1af3fc7c))
### Bug Fixes
* do not override XDG_DATA_HOME/XDG_CONFIG_HOME if they are already set ([#472](https://github.com/pocket-id/pocket-id/issues/472)) ([22725d3](https://github.com/pocket-id/pocket-id/commit/22725d30f4115ffe17625379f56affedfe116778))
* pass context to methods that were missing it ([#487](https://github.com/pocket-id/pocket-id/issues/487)) ([4c33793](https://github.com/pocket-id/pocket-id/commit/4c33793678709eb4981be2c1fd5803bace5f5939))
* prevent deadlock when trying to delete LDAP users ([#471](https://github.com/pocket-id/pocket-id/issues/471)) ([270c303](https://github.com/pocket-id/pocket-id/commit/270c30334dc36f215a67f873283a9d6fcd14d065))
* rootless Caddy data and configuration ([#470](https://github.com/pocket-id/pocket-id/issues/470)) ([76b753f](https://github.com/pocket-id/pocket-id/commit/76b753f9f2a6a4f1af09359530e30844b03ac39b))
## [](https://github.com/pocket-id/pocket-id/compare/v0.48.0...v) (2025-04-20)
### Features
* add ability to disable API key expiration email ([9122e75](https://github.com/pocket-id/pocket-id/commit/9122e75101ad39a40135ccf931eb2bfd351b5db6))
* add ability to send login code via email ([#457](https://github.com/pocket-id/pocket-id/issues/457)) ([fe1c4b1](https://github.com/pocket-id/pocket-id/commit/fe1c4b18cdcc46a4256e0c111b34f1ce00f8e0e1))
* add description to callback URL inputs ([eb689eb](https://github.com/pocket-id/pocket-id/commit/eb689eb56ec9eaf8b0fb1485040e26f841b9225d))
* send email to user when api key expires within 7 days ([#451](https://github.com/pocket-id/pocket-id/issues/451)) ([26f01f2](https://github.com/pocket-id/pocket-id/commit/26f01f205be01fb8abd8c2e564c90c0fc4480ea5))
### Bug Fixes
* disable animations not respected on authorize and logout page ([e571996](https://github.com/pocket-id/pocket-id/commit/e571996cb57d04232c1f47ab337ad656f48bb3cb))
* hide alternative sign in button if user is already authenticated ([4e05b82](https://github.com/pocket-id/pocket-id/commit/4e05b82f02740a4bae07cec6c6a64acd34ca0fc3))
* locale change in dropdown doesn't work on first try ([60bad9e](https://github.com/pocket-id/pocket-id/commit/60bad9e9859d81c9967e6939e1ed10a65145a936))
* remove limit of 20 callback URLs ([c37a3e0](https://github.com/pocket-id/pocket-id/commit/c37a3e0ed177c3bd2b9a618d1f4b0709004478b0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.47.0...v) (2025-04-18)
### Features
* add gif support for logo and background image ([56a8b5d](https://github.com/pocket-id/pocket-id/commit/56a8b5d0c02643f869b77cf8475ddf2f9473880b))
* disable/enable users ([#437](https://github.com/pocket-id/pocket-id/issues/437)) ([c843a60](https://github.com/pocket-id/pocket-id/commit/c843a60131b813177b1e270c4f5d97613c700efa))
### Bug Fixes
* add "type" as reserved claim ([0111a58](https://github.com/pocket-id/pocket-id/commit/0111a58dac0342c5ac2fa25a050e8773810d2b0a))
* callback URL doesn't get rejected if it starts with a different string ([f0dce41](https://github.com/pocket-id/pocket-id/commit/f0dce41fbc5649b3a8fe65de36ca20efa521b880))
* profile picture empty for users without first or last name ([#449](https://github.com/pocket-id/pocket-id/issues/449)) ([5a6dfd9](https://github.com/pocket-id/pocket-id/commit/5a6dfd9e505f4c84e91b4b378b082fab10e8a8a8))
* user querying fails on global audit log page with Postgres ([84f1d5c](https://github.com/pocket-id/pocket-id/commit/84f1d5c906ec3f9a74ad3d2f36526eea847af5dd))
## [](https://github.com/pocket-id/pocket-id/compare/v0.46.0...v) (2025-04-16)
### Features
* add qrcode representation of one time link ([#424](https://github.com/pocket-id/pocket-id/issues/424)) ([#436](https://github.com/pocket-id/pocket-id/issues/436)) ([abf17f6](https://github.com/pocket-id/pocket-id/commit/abf17f62114a2de549b62cec462b9b0659ee23a7))
* disable animations setting toggle ([#442](https://github.com/pocket-id/pocket-id/issues/442)) ([b45cf68](https://github.com/pocket-id/pocket-id/commit/b45cf68295975f51777dab95950b98b8db0a9ae5))
### Bug Fixes
* define token type as claim for better client compatibility ([adf7458](https://github.com/pocket-id/pocket-id/commit/adf74586afb6ef9a00fb122c150b0248c5bc23f0))
## [](https://github.com/pocket-id/pocket-id/compare/v0.45.0...v) (2025-04-13)
### Features
* global audit log ([#320](https://github.com/pocket-id/pocket-id/issues/320)) ([b65e693](https://github.com/pocket-id/pocket-id/commit/b65e693e12be2e7e4cb75a74d6fd43bacb3f6a94))
* implement token introspection ([#405](https://github.com/pocket-id/pocket-id/issues/405)) ([7e5d16b](https://github.com/pocket-id/pocket-id/commit/7e5d16be9bdfccfa113924547e313886681d11bb))
* modernize ui ([#381](https://github.com/pocket-id/pocket-id/issues/381)) ([9881a1d](https://github.com/pocket-id/pocket-id/commit/9881a1df9efe32608ab116db71c0e4f66dae171c))
* **onboarding:** Added button when you don't have a passkey added. ([#426](https://github.com/pocket-id/pocket-id/issues/426)) ([72061ba](https://github.com/pocket-id/pocket-id/commit/72061ba4278a007437cee3a205c3076d58bde644))
### Bug Fixes
* add missing rollback for LDAP sync ([658a9ca](https://github.com/pocket-id/pocket-id/commit/658a9ca6dd8d2304ff3639a000bab02e91ff68a6))
* create reusable default profile pictures ([#406](https://github.com/pocket-id/pocket-id/issues/406)) ([734c681](https://github.com/pocket-id/pocket-id/commit/734c6813eaef166235ae801747e3652d17ae0e2a))
* ensure file descriptors are closed + other bugs ([#413](https://github.com/pocket-id/pocket-id/issues/413)) ([2f76461](https://github.com/pocket-id/pocket-id/commit/2f7646105e26423f47cbe49dae97e40c4a01a025))
* ensure indexes on audit_logs table ([#415](https://github.com/pocket-id/pocket-id/issues/415)) ([9e88926](https://github.com/pocket-id/pocket-id/commit/9e88926283a7a663bfc7fd4f4aa16bd02f614176))
* ignore profile picture cache after profile picture gets updated ([4ba6893](https://github.com/pocket-id/pocket-id/commit/4ba68938dd2a631c633fcb65d8c35cb039d3f59c))
* improve LDAP error handling ([#425](https://github.com/pocket-id/pocket-id/issues/425)) ([796bc7e](https://github.com/pocket-id/pocket-id/commit/796bc7ed3453839b1dc8d846b71fe9fac9a2d646))
* use transactions when operations involve multiple database queries ([#392](https://github.com/pocket-id/pocket-id/issues/392)) ([ec626ee](https://github.com/pocket-id/pocket-id/commit/ec626ee7977306539fd1d70cc9091590f0a54af6))
* use UUID for temporary file names ([ccc18d7](https://github.com/pocket-id/pocket-id/commit/ccc18d716f16a7ef1775d30982e2ba7b5ff159a6))
### Performance Improvements
* run async operations in parallel in server load functions ([1762629](https://github.com/pocket-id/pocket-id/commit/17626295964244c5582806bd0f413da2c799d5ad))
## [](https://github.com/pocket-id/pocket-id/compare/v0.44.0...v) (2025-03-29)
### Features
* add support for ECDSA and EdDSA keys ([#359](https://github.com/pocket-id/pocket-id/issues/359)) ([96876a9](https://github.com/pocket-id/pocket-id/commit/96876a99c586508b72c27669ab200ff6a29db771))
### Bug Fixes
* ldap users aren't deleted if removed from ldap server ([7e65827](https://github.com/pocket-id/pocket-id/commit/7e658276f04d08a1f5117796e55d45e310204dab))
* use value receiver for `AuditLogData` ([cbd1bbd](https://github.com/pocket-id/pocket-id/commit/cbd1bbdf741eedd03e93598d67623c75c74b6212))
* use WAL for SQLite by default and set busy_timeout ([#388](https://github.com/pocket-id/pocket-id/issues/388)) ([519d58d](https://github.com/pocket-id/pocket-id/commit/519d58d88c906abc5139e35933bdeba0396c10a2))
## [](https://github.com/pocket-id/pocket-id/compare/v0.43.1...v) (2025-03-25)
### Features
* add OIDC refresh_token support ([#325](https://github.com/pocket-id/pocket-id/issues/325)) ([b8dcda8](https://github.com/pocket-id/pocket-id/commit/b8dcda80497e554d163a370eff81fe000f8831f4))
### Bug Fixes
* hash the refresh token in the DB (security) ([#379](https://github.com/pocket-id/pocket-id/issues/379)) ([8c96381](https://github.com/pocket-id/pocket-id/commit/8c963818bb90c84dac04018eec93790900d4b0ce))
* skip ldap objects without a valid unique id ([#376](https://github.com/pocket-id/pocket-id/issues/376)) ([cdfe816](https://github.com/pocket-id/pocket-id/commit/cdfe8161d4429bdfe879887fe0b563a67c14f50b))
* stop container if Caddy, the frontend or the backend fails ([e6f5019](https://github.com/pocket-id/pocket-id/commit/e6f50191cf05a5d0ac0e0000cf66423646f1920e))
## [](https://github.com/pocket-id/pocket-id/compare/v0.43.0...v) (2025-03-20)
### Bug Fixes
* wrong base locale causes crash ([3120ebf](https://github.com/pocket-id/pocket-id/commit/3120ebf239b90f0bc0a0af33f30622e034782398))
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.1...v) (2025-03-20)
### Features
* add support for translations ([#349](https://github.com/pocket-id/pocket-id/issues/349)) ([269b5a3](https://github.com/pocket-id/pocket-id/commit/269b5a3c9249bb8081c74741141d3d5a69ea42a2))
* **passkeys:** name new passkeys based on agguids ([#332](https://github.com/pocket-id/pocket-id/issues/332)) ([041c565](https://github.com/pocket-id/pocket-id/commit/041c565dc10f15edb3e8ab58e9a4df5e48a2a6d3))
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.0...v) (2025-03-18)
### Bug Fixes
* kid not added to JWTs ([f7e36a4](https://github.com/pocket-id/pocket-id/commit/f7e36a422ea6b5327360c9a13308ae408ff7fffe))
## [](https://github.com/pocket-id/pocket-id/compare/v0.41.0...v) (2025-03-18)
### Features
* store keys as JWK on disk ([#339](https://github.com/pocket-id/pocket-id/issues/339)) ([a7c9741](https://github.com/pocket-id/pocket-id/commit/a7c9741802667811c530ef4e6313b71615ec6a9b))
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.1...v) (2025-03-18)
### Features
* **profile-picture:** allow reset of profile picture ([#355](https://github.com/pocket-id/pocket-id/issues/355)) ([8f14618](https://github.com/pocket-id/pocket-id/commit/8f146188d57b5c08a4c6204674c15379232280d8))
### Bug Fixes
* own avatar not loading ([#351](https://github.com/pocket-id/pocket-id/issues/351)) ([0423d35](https://github.com/pocket-id/pocket-id/commit/0423d354f533d2ff4fd431859af3eea7d4d7044f))
## [](https://github.com/pocket-id/pocket-id/compare/v0.40.0...v) (2025-03-16)
### Bug Fixes
* API keys not working if sqlite is used ([8ead0be](https://github.com/pocket-id/pocket-id/commit/8ead0be8cd0cfb542fe488b7251cfd5274975ae1))
* caching for own profile picture ([e45d9e9](https://github.com/pocket-id/pocket-id/commit/e45d9e970d327a5120ff9fb0c8d42df8af69bb38))
* email logo icon displaying too big ([#336](https://github.com/pocket-id/pocket-id/issues/336)) ([b483e2e](https://github.com/pocket-id/pocket-id/commit/b483e2e92fdb528e7de026350a727d6970227426))
* emails are considered as medium spam by rspamd ([#337](https://github.com/pocket-id/pocket-id/issues/337)) ([39b7f66](https://github.com/pocket-id/pocket-id/commit/39b7f6678c98cadcdc3abfbcb447d8eb0daa9eb0))
* Fixes and performance improvements in utils package ([#331](https://github.com/pocket-id/pocket-id/issues/331)) ([348192b](https://github.com/pocket-id/pocket-id/commit/348192b9d7e2698add97810f8fba53d13d0df018))
* remove custom claim key restrictions ([9f28503](https://github.com/pocket-id/pocket-id/commit/9f28503d6c73d3521d1309bee055704a0507e9b5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.39.0...v) (2025-03-13)
### Features
* allow setting path where keys are stored ([#327](https://github.com/pocket-id/pocket-id/issues/327)) ([7b654c6](https://github.com/pocket-id/pocket-id/commit/7b654c6bd111ddcddd5e3450cbf326d9cf1777b6))
### Bug Fixes
* **docker:** missing write permissions on scripts ([ec4b41a](https://github.com/pocket-id/pocket-id/commit/ec4b41a1d26ea00bb4a95f654ac4cc745b2ce2e8))
## [](https://github.com/pocket-id/pocket-id/compare/v0.38.0...v) (2025-03-11)
### Features
* api key authentication ([#291](https://github.com/pocket-id/pocket-id/issues/291)) ([62915d8](https://github.com/pocket-id/pocket-id/commit/62915d863a4adc09cf467b75c414a045be43c2bb))
### Bug Fixes
* alternative login method link on mobile ([9ef2ddf](https://github.com/pocket-id/pocket-id/commit/9ef2ddf7963c6959992f3a5d6816840534e926e9))
## [](https://github.com/pocket-id/pocket-id/compare/v0.37.0...v) (2025-03-10)
### Features
* add env variable to disable update check ([31198fe](https://github.com/pocket-id/pocket-id/commit/31198feec2ae77dd6673c42b42002871ddd02d37))
### Bug Fixes
* redirection not correctly if signing in with email code ([e5ec264](https://github.com/pocket-id/pocket-id/commit/e5ec264bfd535752565bcc107099a9df5cb8aba7))
* typo in account settings ([#307](https://github.com/pocket-id/pocket-id/issues/307)) ([c822192](https://github.com/pocket-id/pocket-id/commit/c8221921245deb3008f655740d1a9460dcdab2fc))
## [](https://github.com/pocket-id/pocket-id/compare/v0.36.0...v) (2025-03-10)
### Features
* **account:** add ability to sign in with login code ([#271](https://github.com/pocket-id/pocket-id/issues/271)) ([eb1426e](https://github.com/pocket-id/pocket-id/commit/eb1426ed2684b5ddd185db247a8e082b28dfd014))
* increase default item count per page ([a9713cf](https://github.com/pocket-id/pocket-id/commit/a9713cf6a1e3c879dc773889b7983e51bbe3c45b))
### Bug Fixes
* add back setup page ([6a8dd84](https://github.com/pocket-id/pocket-id/commit/6a8dd84ca9396ff3369385af22f7e1f081bec2b2))
* add timeout to update check ([04efc36](https://github.com/pocket-id/pocket-id/commit/04efc3611568a0b0127b542b8cc252d9e783af46))
* make sorting consistent around tables ([8e344f1](https://github.com/pocket-id/pocket-id/commit/8e344f1151628581b637692a1de0e48e7235a22d))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.6...v) (2025-03-06)
### Features
* display groups on the account page ([#296](https://github.com/pocket-id/pocket-id/issues/296)) ([0f14a93](https://github.com/pocket-id/pocket-id/commit/0f14a93e1d6a723b0994ba475b04702646f04464))
* enable sd_notify support ([#277](https://github.com/pocket-id/pocket-id/issues/277)) ([91f254c](https://github.com/pocket-id/pocket-id/commit/91f254c7bb067646c42424c5c62ebcd90a0c8792))
### Bug Fixes
* default sorting on tables ([#299](https://github.com/pocket-id/pocket-id/issues/299)) ([ff34e3b](https://github.com/pocket-id/pocket-id/commit/ff34e3b925321c80e9d7d42d0fd50e397d198435))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.5...v) (2025-03-03)
### Bug Fixes
* support `LOGIN` authentication method for SMTP ([#292](https://github.com/pocket-id/pocket-id/issues/292)) ([2d733fc](https://github.com/pocket-id/pocket-id/commit/2d733fc79faefca23d54b22768029c3ba3427410))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.4...v) (2025-03-03)
### Bug Fixes
* profile picture orientation if image is rotated with EXIF ([1026ee4](https://github.com/pocket-id/pocket-id/commit/1026ee4f5b5c7fda78b65c94a5d0f899525defd1))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.3...v) (2025-03-01)
### Bug Fixes
* add `groups` scope and claim to well known endpoint ([4bafee4](https://github.com/pocket-id/pocket-id/commit/4bafee4f58f5a76898cf66d6192916d405eea389))
* profile picture of other user can't be updated ([#273](https://github.com/pocket-id/pocket-id/issues/273)) ([ef25f6b](https://github.com/pocket-id/pocket-id/commit/ef25f6b6b84b52f1310d366d40aa3769a6fe9bef))
* support POST for OIDC userinfo endpoint ([1652cc6](https://github.com/pocket-id/pocket-id/commit/1652cc65f3f966d018d81a1ae22abb5ff1b4c47b))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.2...v) (2025-02-25)
### Bug Fixes
* add option to manually select SMTP TLS method ([#268](https://github.com/pocket-id/pocket-id/issues/268)) ([01a9de0](https://github.com/pocket-id/pocket-id/commit/01a9de0b04512c62d0f223de33d711f93c49b9cc))
* **ldap:** sync error if LDAP user collides with an existing user ([fde951b](https://github.com/pocket-id/pocket-id/commit/fde951b543281fedf9f602abae26b50881e3d157))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.1...v) (2025-02-24)
### Bug Fixes
* delete profile picture if user gets deleted ([9a167d4](https://github.com/pocket-id/pocket-id/commit/9a167d4076872e5e3e5d78d2a66ef7203ca5261b))
* updating profile picture of other user updates own profile picture ([887c5e4](https://github.com/pocket-id/pocket-id/commit/887c5e462a50c8fb579ca6804f1a643d8af78fe8))
## [](https://github.com/pocket-id/pocket-id/compare/v0.35.0...v) (2025-02-22)
### Bug Fixes
* add validation that `PUBLIC_APP_URL` can't contain a path ([a6ae7ae](https://github.com/pocket-id/pocket-id/commit/a6ae7ae28713f7fc8018ae2aa7572986df3e1a5b))
* binary profile picture can't be imported from LDAP ([840a672](https://github.com/pocket-id/pocket-id/commit/840a672fc35ca8476caf86d7efaba9d54bce86aa))
## [](https://github.com/pocket-id/pocket-id/compare/v0.34.0...v) (2025-02-19)
### Features
* add ability to upload a profile picture ([#244](https://github.com/pocket-id/pocket-id/issues/244)) ([652ee6a](https://github.com/pocket-id/pocket-id/commit/652ee6ad5d6c46f0d35c955ff7bb9bdac6240ca6))
### Bug Fixes
* app config strings starting with a number are parsed incorrectly ([816c198](https://github.com/pocket-id/pocket-id/commit/816c198a42c189cb1f2d94885d2e3623e47e2848))
* emails do not get rendered correctly in Gmail ([dca9e7a](https://github.com/pocket-id/pocket-id/commit/dca9e7a11a3ba5d3b43a937f11cb9d16abad2db5))
## [](https://github.com/pocket-id/pocket-id/compare/v0.33.0...v) (2025-02-16)
### Features
* add LDAP group membership attribute ([#236](https://github.com/pocket-id/pocket-id/issues/236)) ([39b46e9](https://github.com/pocket-id/pocket-id/commit/39b46e99a9b930ea39cf640c3080530cfff5be6e))
## [](https://github.com/pocket-id/pocket-id/compare/v0.32.0...v) (2025-02-14)
### Features
* add end session endpoint ([#232](https://github.com/pocket-id/pocket-id/issues/232)) ([7550333](https://github.com/pocket-id/pocket-id/commit/7550333fe2ff6424f3168f63c5179d76767532fd))
### Bug Fixes
* alignment of OIDC client details ([c3980d3](https://github.com/pocket-id/pocket-id/commit/c3980d3d28a7158a4dc9369af41f185b891e485e))
* layout of OIDC client details page on mobile ([3de1301](https://github.com/pocket-id/pocket-id/commit/3de1301fa84b3ab4fff4242d827c7794d44910f2))
* show "Sync Now" and "Test Email" button even if UI config is disabled ([4d0fff8](https://github.com/pocket-id/pocket-id/commit/4d0fff821e2245050ce631b4465969510466dfae))
## [](https://github.com/pocket-id/pocket-id/compare/v0.31.0...v) (2025-02-13)
### Features
* add ability to set custom Geolite DB URL ([2071d00](https://github.com/pocket-id/pocket-id/commit/2071d002fc5c3b5ff7a3fca6a5c99f5517196853))
## [](https://github.com/pocket-id/pocket-id/compare/v0.30.0...v) (2025-02-12)
### Features
* add ability to override the UI configuration with environment variables ([4e85842](https://github.com/pocket-id/pocket-id/commit/4e858420e9d9713e19f3b35c45c882403717f72f))
* add warning for only having one passkey configured ([#220](https://github.com/pocket-id/pocket-id/issues/220)) ([39e403d](https://github.com/pocket-id/pocket-id/commit/39e403d00f3870f9e960427653a1d9697da27a6f))
* display source in user and group table ([#225](https://github.com/pocket-id/pocket-id/issues/225)) ([9ed2adb](https://github.com/pocket-id/pocket-id/commit/9ed2adb0f8da13725fd9a4ef6a7798c377d13513))
### Bug Fixes
* user linking in ldap group sync ([#222](https://github.com/pocket-id/pocket-id/issues/222)) ([2d78349](https://github.com/pocket-id/pocket-id/commit/2d78349b381d7ca10f47d3c03cef685a576b1b49))
## [](https://github.com/pocket-id/pocket-id/compare/v0.29.0...v) (2025-02-08)
### Features
* add custom ldap search filters ([#216](https://github.com/pocket-id/pocket-id/issues/216)) ([626f87d](https://github.com/pocket-id/pocket-id/commit/626f87d59211f4129098b91dc1d020edb4aca692))
* update host configuration to allow external access ([#218](https://github.com/pocket-id/pocket-id/issues/218)) ([bea1158](https://github.com/pocket-id/pocket-id/commit/bea115866fd8e4b15d3281c422d2fb72312758b1))
## [](https://github.com/pocket-id/pocket-id/compare/v0.28.1...v) (2025-02-05)

View File

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

View File

@@ -1,44 +1,52 @@
# This file uses multi-stage builds to build the application from source, including the front-end
# Tags passed to "go build"
ARG BUILD_TAGS=""
# Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder
WORKDIR /app/frontend
WORKDIR /build
COPY ./frontend/package*.json ./
RUN npm ci
COPY ./frontend ./
RUN npm run build
RUN npm prune --production
RUN BUILD_OUTPUT_PATH=dist npm run build
# Stage 2: Build Backend
FROM golang:1.23-alpine AS backend-builder
WORKDIR /app/backend
FROM golang:1.24-alpine AS backend-builder
ARG BUILD_TAGS
WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download
RUN apk add --no-cache gcc musl-dev
COPY ./backend ./
WORKDIR /app/backend/cmd
RUN CGO_ENABLED=1 GOOS=linux go build -o /app/backend/pocket-id-backend .
COPY --from=frontend-builder /build/dist ./frontend/dist
COPY .version .version
WORKDIR /build/cmd
RUN VERSION=$(cat /build/.version) \
CGO_ENABLED=0 \
GOOS=linux \
go build \
-tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
-trimpath \
-o /build/pocket-id-backend \
.
# Stage 3: Production Image
FROM node:22-alpine
# Delete default node user
RUN deluser --remove-home node
RUN apk add --no-cache caddy curl su-exec
COPY ./reverse-proxy /etc/caddy/
FROM alpine
WORKDIR /app
COPY --from=frontend-builder /app/frontend/build ./frontend/build
COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
RUN apk add --no-cache curl su-exec
COPY ./scripts ./scripts
RUN chmod +x ./scripts/*.sh
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
COPY ./scripts/docker /app/docker
EXPOSE 80
RUN chmod +x /app/pocket-id && \
find /app/docker -name "*.sh" -exec chmod +x {} \;
EXPOSE 1411
ENV APP_ENV=production
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
CMD ["sh", "./scripts/docker/entrypoint.sh"]
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
CMD ["/app/pocket-id"]

20
Dockerfile-prebuilt Normal file
View File

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

12
backend/.air.toml Normal file
View File

@@ -0,0 +1,12 @@
root = "."
tmp_dir = ".bin"
[build]
bin = "./.bin/pocket-id"
cmd = "CGO_ENABLED=0 go build -o ./.bin/pocket-id ./cmd"
exclude_dir = ["resources", ".bin", "data"]
exclude_regex = [".*_test\\.go"]
stop_on_error = true
[misc]
clean_on_exit = true

View File

@@ -0,0 +1,9 @@
# Sample .env file for development
# All environment variables can be found on https://pocket-id.org/docs/configuration/environment-variables
APP_ENV=development
# Set the APP_URL to the URL where the frontend is listening
# In the development environment the backend gets proxied by the frontend
APP_URL=http://localhost:3000
PORT=1411
MAXMIND_LICENSE_KEY=your_license_key

View File

@@ -1,10 +0,0 @@
APP_ENV=production
PUBLIC_APP_URL=http://localhost
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
DB_PROVIDER=sqlite
# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log
SQLITE_DB_PATH=data/pocket-id.db
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
UPLOAD_PATH=data/uploads
PORT=8080
HOST=localhost

1
backend/.gitignore vendored
View File

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

64
backend/.golangci.yml Normal file
View File

@@ -0,0 +1,64 @@
version: "2"
run:
tests: true
timeout: 5m
linters:
default: none
enable:
- asasalint
- asciicheck
- bidichk
- bodyclose
- contextcheck
- copyloopvar
- durationcheck
- errcheck
- errchkjson
- errorlint
- exhaustive
- gocheckcompilerdirectives
- gochecksumtype
- gocognit
- gocritic
- gosec
- gosmopolitan
- govet
- ineffassign
- loggercheck
- makezero
- musttag
- nilerr
- nilnesserr
- noctx
- protogetter
- reassign
- recvcheck
- rowserrcheck
- spancheck
- sqlclosecheck
- staticcheck
- testifylint
- unused
- usestdlibvars
- zerologlint
exclusions:
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
paths:
- third_party$
- builtin$
- examples$
- internal/service/test_service.go
formatters:
enable:
- goimports
exclusions:
generated: lax
paths:
- third_party$
- builtin$
- examples$

View File

@@ -1,9 +1,43 @@
package main
import (
"flag"
"fmt"
"log"
_ "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"
)
// @title Pocket ID API
// @version 1.0
// @description.markdown
func main() {
bootstrap.Bootstrap()
// 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())
}
}

View File

@@ -0,0 +1,9 @@
//go:build exclude_frontend
package frontend
import "github.com/gin-gonic/gin"
func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded
}

View File

@@ -0,0 +1,77 @@
//go:build !exclude_frontend
package frontend
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
)
//go:embed all:dist/*
var frontendFS embed.FS
func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist")
if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err)
}
cacheMaxAge := time.Hour * 24
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
router.NoRoute(func(c *gin.Context) {
// Try to serve the requested file
path := strings.TrimPrefix(c.Request.URL.Path, "/")
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
// File doesn't exist, serve index.html instead
c.Request.URL.Path = "/"
}
fileServer.ServeHTTP(c.Writer, c.Request)
})
return nil
}
// FileServerWithCaching wraps http.FileServer to add caching headers
type FileServerWithCaching struct {
root http.FileSystem
lastModified time.Time
cacheMaxAge int
lastModifiedHeaderValue string
cacheControlHeaderValue string
}
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
return &FileServerWithCaching{
root: root,
lastModified: time.Now(),
cacheMaxAge: maxAge,
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
}
}
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// Check if the client has a cached version
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
// Client's cached version is up to date
w.WriteHeader(http.StatusNotModified)
return
}
}
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
http.FileServer(f.root).ServeHTTP(w, r)
}

View File

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

View File

@@ -1,41 +1,71 @@
module github.com/pocket-id/pocket-id/backend
go 1.23.1
go 1.24.0
require (
github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2
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/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.24.0
github.com/go-playground/validator/v10 v10.25.0
github.com/go-webauthn/webauthn v0.11.2
github.com/golang-jwt/jwt/v5 v5.2.1
github.com/golang-migrate/migrate/v4 v4.18.2
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
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/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
golang.org/x/crypto v0.32.0
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/driver/sqlite v1.5.7
gorm.io/gorm v1.25.12
)
require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.12.8 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.12.10 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect
github.com/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/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect
github.com/goccy/go-json v0.10.4 // indirect
github.com/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/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
@@ -46,27 +76,61 @@ require (
github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.9 // indirect
github.com/kr/pretty v0.3.1 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/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/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // 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.13.0 // indirect
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
golang.org/x/net v0.34.0 // indirect
golang.org/x/sync v0.10.0 // indirect
golang.org/x/sys v0.29.0 // indirect
golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.36.4 // 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
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect
modernc.org/sqlite v1.37.0 // indirect
)

View File

@@ -6,22 +6,37 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.12.8 h1:4xYRVRlXIgvSZ4e8iVTlMF5szgpXd4AfvuWgA8I8lgs=
github.com/bytedance/sonic v1.12.8/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
github.com/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/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/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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
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/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/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=
@@ -30,6 +45,12 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
@@ -40,12 +61,17 @@ 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/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-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/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
@@ -56,29 +82,36 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.24.0 h1:KHQckvo8G6hlWnrPX4NJJ+aBfWNAE/HH+qdL2cBpCmg=
github.com/go-playground/validator/v10 v10.24.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
github.com/go-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/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
github.com/goccy/go-json v0.10.4/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/golang/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.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/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/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=
@@ -117,16 +150,30 @@ github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbd
github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/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.9 h1:66ze0taIn2H33fBvCkXuv9BmCwDfafmiIVpKV9kKGuY=
github.com/klauspost/cpuid/v2 v2.2.9/go.mod h1:rqkxqrZ1EhYM9G+hXH7YdowN5R5RGN6NK4QwQ3WMXF8=
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/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=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-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/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
@@ -148,6 +195,10 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -156,22 +207,33 @@ github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
github.com/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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8=
github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4=
github.com/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/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=
@@ -186,20 +248,60 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
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.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
go.opentelemetry.io/otel/metric v1.29.0 h1:vPf/HFWTNkPu1aYeIsc98l4ktOQaL6LeSoeV2g+8YLc=
go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8=
go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
go.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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.13.0 h1:KCkqVVV1kGg0X87TFysjCJ8MxtZEIU4Ja/yXGeoECdA=
golang.org/x/arch v0.13.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
golang.org/x/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=
@@ -207,15 +309,20 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
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.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
golang.org/x/crypto v0.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/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/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -227,16 +334,17 @@ 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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
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/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=
@@ -249,8 +357,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.29.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/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=
@@ -268,8 +376,9 @@ 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 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -278,9 +387,17 @@ 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/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
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=
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=
@@ -290,8 +407,30 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
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=
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/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/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/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

@@ -38,7 +38,6 @@ func initApplicationImages() {
log.Fatalf("Error copying file: %v", err)
}
}
}
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
@@ -55,6 +54,11 @@ func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
}
func getImageNameWithoutExtension(fileName string) string {
splitted := strings.Split(fileName, ".")
return strings.Join(splitted[:len(splitted)-1], ".")
idx := strings.LastIndexByte(fileName, '.')
if idx < 1 {
// No dot found, or fileName starts with a dot
return fileName
}
return fileName[:idx]
}

View File

@@ -1,15 +1,72 @@
package bootstrap
import (
"context"
"fmt"
"log"
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
)
func Bootstrap() {
func Bootstrap() error {
// Get a context that is canceled when the application is stopping
ctx := signals.SignalContext(context.Background())
initApplicationImages()
db := newDatabase()
appConfigService := service.NewAppConfigService(db)
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
}
initRouter(db, appConfigService)
// Connect to the database
db := NewDatabase()
// Create all services
svc, err := initServices(ctx, db, httpClient)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
// Init the job scheduler
scheduler, err := job.NewScheduler()
if err != nil {
return fmt.Errorf("failed to create job scheduler: %w", err)
}
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
if err != nil {
return fmt.Errorf("failed to register scheduled jobs: %w", err)
}
// Init the router
router := initRouter(db, svc)
// Run all background services
// This call blocks until the context is canceled
err = utils.
NewServiceRunner(router, scheduler.Run).
Run(ctx)
if err != nil {
return fmt.Errorf("failed to run services: %w", err)
}
// Invoke all shutdown functions
// We give these a timeout of 5s
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
}

View File

@@ -4,23 +4,26 @@ import (
"errors"
"fmt"
"log"
"net/url"
"os"
"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/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
)
func newDatabase() (db *gorm.DB) {
func NewDatabase() (db *gorm.DB) {
db, err := connectDatabase()
if err != nil {
log.Fatalf("failed to connect to database: %v", err)
@@ -38,6 +41,7 @@ func newDatabase() (db *gorm.DB) {
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 {
@@ -56,17 +60,17 @@ 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: %v", err)
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: %v", err)
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: %v", err)
return fmt.Errorf("failed to apply migrations: %w", err)
}
return nil
@@ -78,9 +82,22 @@ func connectDatabase() (db *gorm.DB, err error) {
// Choose the correct database provider
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
dialector = sqlite.Open(common.EnvConfig.SqliteDBPath)
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
}
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)
if err != nil {
return nil, err
}
dialector = sqlite.Open(connString)
case common.DbProviderPostgres:
dialector = postgres.Open(common.EnvConfig.PostgresConnectionString)
if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
dialector = postgres.Open(common.EnvConfig.DbConnectionString)
default:
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
@@ -91,14 +108,58 @@ func connectDatabase() (db *gorm.DB, err error) {
Logger: getLogger(),
})
if err == nil {
break
} else {
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
return db, nil
}
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
time.Sleep(3 * time.Second)
}
return nil, err
}
// 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)
}
// 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 {
case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout":
qs.Add("_pragma", "busy_timeout("+v[0]+")")
case "_case_sensitive_like", "_cslike":
qs.Add("_pragma", "case_sensitive_like("+v[0]+")")
case "_foreign_keys", "_fk":
qs.Add("_pragma", "foreign_keys("+v[0]+")")
case "_locking_mode", "_locking":
qs.Add("_pragma", "locking_mode("+v[0]+")")
case "_secure_delete":
qs.Add("_pragma", "secure_delete("+v[0]+")")
case "_synchronous", "_sync":
qs.Add("_pragma", "synchronous("+v[0]+")")
default:
// Pass other query-string args as-is
qs[k] = v
}
}
return db, err
connStringUrl.RawQuery = qs.Encode()
return connStringUrl.String(), nil
}
func getLogger() logger.Interface {

View File

@@ -0,0 +1,145 @@
package bootstrap
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseSqliteConnectionString(t *testing.T) {
tests := []struct {
name string
input string
expected string
expectedError bool
}{
{
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",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _timeout to pragma",
input: "file:test.db?_timeout=5000",
expected: "file:test.db?_pragma=busy_timeout%285000%29",
},
{
name: "converts _foreign_keys to pragma",
input: "file:test.db?_foreign_keys=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _fk to pragma",
input: "file:test.db?_fk=1",
expected: "file:test.db?_pragma=foreign_keys%281%29",
},
{
name: "converts _synchronous to pragma",
input: "file:test.db?_synchronous=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _sync to pragma",
input: "file:test.db?_sync=NORMAL",
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
},
{
name: "converts _auto_vacuum to pragma",
input: "file:test.db?_auto_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _vacuum to pragma",
input: "file:test.db?_vacuum=FULL",
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
},
{
name: "converts _case_sensitive_like to pragma",
input: "file:test.db?_case_sensitive_like=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _cslike to pragma",
input: "file:test.db?_cslike=1",
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
},
{
name: "converts _locking_mode to pragma",
input: "file:test.db?_locking_mode=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _locking to pragma",
input: "file:test.db?_locking=EXCLUSIVE",
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
},
{
name: "converts _secure_delete to pragma",
input: "file:test.db?_secure_delete=1",
expected: "file:test.db?_pragma=secure_delete%281%29",
},
{
name: "preserves unrecognized parameters",
input: "file:test.db?mode=rw&cache=shared",
expected: "file:test.db?cache=shared&mode=rw",
},
{
name: "handles multiple parameters",
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
},
{
name: "invalid URL format",
input: "file:invalid#$%^&*@test.db",
expectedError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := parseSqliteConnectionString(tt.input)
if tt.expectedError {
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)
}
})
}
}

View File

@@ -0,0 +1,28 @@
//go:build e2etest
package bootstrap
import (
"log"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// When building for E2E tests, add the e2etest controller
func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
if err != nil {
log.Fatalf("failed to initialize test service: %v", err)
return
}
controller.NewTestController(apiGroup, testService)
},
}
}

View File

@@ -0,0 +1,107 @@
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

@@ -1,20 +1,42 @@
package bootstrap
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"strconv"
"time"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"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/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/systemd"
)
func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc)
if err != nil {
log.Fatalf("failed to init router: %v", err)
}
return runner
}
func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case "production":
@@ -28,59 +50,126 @@ func initRouter(db *gorm.DB, appConfigService *service.AppConfigService) {
r := gin.Default()
r.Use(gin.Logger())
// Initialize services
emailService, err := service.NewEmailService(appConfigService, db)
if err != nil {
log.Fatalf("Unable to create email service: %s", err)
if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil)
}
geoLiteService := service.NewGeoLiteService()
auditLogService := service.NewAuditLogService(db, appConfigService, emailService, geoLiteService)
jwtService := service.NewJwtService(appConfigService)
webauthnService := service.NewWebAuthnService(db, jwtService, auditLogService, appConfigService)
userService := service.NewUserService(db, jwtService, auditLogService, emailService, appConfigService)
customClaimService := service.NewCustomClaimService(db)
oidcService := service.NewOidcService(db, jwtService, appConfigService, auditLogService, customClaimService)
testService := service.NewTestService(db, appConfigService)
userGroupService := service.NewUserGroupService(db, appConfigService)
ldapService := service.NewLdapService(db, appConfigService, userService, userGroupService)
if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware("pocket-id-backend"))
}
rateLimitMiddleware := middleware.NewRateLimitMiddleware()
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
r.Use(rateLimitMiddleware.Add(rate.Every(time.Second), 60))
r.Use(middleware.NewJwtAuthMiddleware(jwtService, true).Add(false))
job.RegisterLdapJobs(ldapService, appConfigService)
job.RegisterDbCleanupJobs(db)
err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
log.Println("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err)
}
// Initialize middleware for specific routes
jwtAuthMiddleware := middleware.NewJwtAuthMiddleware(jwtService, false)
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
// Set up API routes
apiGroup := r.Group("/api")
controller.NewWebauthnController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), webauthnService, appConfigService)
controller.NewOidcController(apiGroup, jwtAuthMiddleware, fileSizeLimitMiddleware, oidcService, jwtService)
controller.NewUserController(apiGroup, jwtAuthMiddleware, middleware.NewRateLimitMiddleware(), userService, appConfigService)
controller.NewAppConfigController(apiGroup, jwtAuthMiddleware, appConfigService, emailService, ldapService)
controller.NewAuditLogController(apiGroup, auditLogService, jwtAuthMiddleware)
controller.NewUserGroupController(apiGroup, jwtAuthMiddleware, userGroupService)
controller.NewCustomClaimController(apiGroup, jwtAuthMiddleware, customClaimService)
apiGroup := r.Group("/api", rateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
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.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
// Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" {
controller.NewTestController(apiGroup, testService)
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
}
// Set up base routes
baseGroup := r.Group("/")
controller.NewWellKnownController(baseGroup, jwtService)
baseGroup := r.Group("/", rateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService)
// Run the server
if err := r.Run(common.EnvConfig.Host + ":" + common.EnvConfig.Port); err != nil {
log.Fatal(err)
// Set up healthcheck routes
// These are not rate-limited
controller.NewHealthzController(r)
// Set up the server
srv := &http.Server{
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: r,
}
// Set up the listener
network := "tcp"
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
if common.EnvConfig.UnixSocket != "" {
network = "unix"
addr = common.EnvConfig.UnixSocket
}
listener, err := net.Listen(network, addr)
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)
// Start the server in a background goroutine
go func() {
defer listener.Close()
// Next call blocks until the server is shut down
srvErr := srv.Serve(listener)
if srvErr != http.ErrServerClosed {
log.Fatalf("Error starting app server: %v", srvErr)
}
}()
// Notify systemd that we are ready
err = systemd.SdNotifyReady()
if err != nil {
// Log the error only
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
}
// Block until the context is canceled
<-ctx.Done()
// Handle graceful shutdown
// Note we use the background context here as ctx has been canceled already
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
shutdownCancel()
if shutdownErr != nil {
// Log the error only (could be context canceled)
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
}
return nil
}
return runFn, nil
}

View File

@@ -0,0 +1,40 @@
package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/job"
)
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
}
err = scheduler.RegisterGeoLiteUpdateJobs(ctx, svc.geoLiteService)
if err != nil {
return fmt.Errorf("failed to register GeoLite DB update service: %w", err)
}
err = scheduler.RegisterDbCleanupJobs(ctx, db)
if err != nil {
return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterFileCleanupJobs(ctx, db)
if err != nil {
return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err)
}
err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyService, svc.appConfigService)
if err != nil {
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
}
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
if err != nil {
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
}
return nil
}

View File

@@ -0,0 +1,56 @@
package bootstrap
import (
"context"
"fmt"
"net/http"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type services struct {
appConfigService *service.AppConfigService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
}
// Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
svc = &services{}
svc.appConfigService = service.NewAppConfigService(ctx, db)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create email service: %w", err)
}
svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService = service.NewJwtService(svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
svc.customClaimService = service.NewCustomClaimService(db)
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
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.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
return svc, nil
}

View File

@@ -0,0 +1,82 @@
package cmds
import (
"context"
"errors"
"fmt"
"time"
"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())
// 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")
}
// 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)
}
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)
}
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
}

View File

@@ -2,6 +2,7 @@ package common
import (
"log"
"net/url"
"github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload"
@@ -10,50 +11,86 @@ import (
type DbProvider string
const (
DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres"
// TracerName should be passed to otel.Tracer, trace.SpanFromContext when creating custom spans.
TracerName = "github.com/pocket-id/pocket-id/backend/tracing"
// MeterName should be passed to otel.Meter when create custom metrics.
MeterName = "github.com/pocket-id/pocket-id/backend/metrics"
)
const (
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"
)
type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"`
AppURL string `env:"PUBLIC_APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"`
SqliteDBPath string `env:"SQLITE_DB_PATH"`
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"`
UploadPath string `env:"UPLOAD_PATH"`
Port string `env:"BACKEND_PORT"`
Host string `env:"HOST"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
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"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
}
var EnvConfig = &EnvConfigSchema{
AppEnv: "production",
DbProvider: "sqlite",
SqliteDBPath: "data/pocket-id.db",
PostgresConnectionString: "",
UploadPath: "data/uploads",
AppURL: "http://localhost",
Port: "8080",
Host: "localhost",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
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: "",
UnixSocketMode: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
}
func init() {
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
log.Fatal(err)
}
// Validate the environment variables
if EnvConfig.DbProvider != DbProviderSqlite && EnvConfig.DbProvider != DbProviderPostgres {
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'")
}
if EnvConfig.DbProvider == DbProviderPostgres && EnvConfig.PostgresConnectionString == "" {
log.Fatal("Missing POSTGRES_CONNECTION_STRING environment variable")
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
if err != nil {
log.Fatal("APP_URL is not a valid URL")
}
if EnvConfig.DbProvider == DbProviderSqlite && EnvConfig.SqliteDBPath == "" {
log.Fatal("Missing SQLITE_DB_PATH environment variable")
if parsedAppUrl.Path != "" {
log.Fatal("APP_URL must not contain a path")
}
}

View File

@@ -1,6 +1,7 @@
package common
import (
"errors"
"fmt"
"net/http"
)
@@ -17,10 +18,16 @@ type AlreadyInUseError struct {
}
func (e *AlreadyInUseError) Error() string {
return fmt.Sprintf("%s is already in use", e.Property)
return e.Property + " is already in use"
}
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
func (e *AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError
x := &AlreadyInUseError{}
return errors.As(target, &x)
}
type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
@@ -31,6 +38,13 @@ type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {
return "Token is invalid"
}
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{}
func (e *OidcMissingAuthorizationError) Error() string { return "missing authorization" }
@@ -51,11 +65,23 @@ type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcMissingCallbackURLError struct{}
func (e *OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this"
}
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string {
@@ -68,11 +94,6 @@ type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type InvalidCredentialsError struct{}
func (e *InvalidCredentialsError) Error() string { return "no user found with provided credentials" }
func (e *InvalidCredentialsError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct {
MaxSize string
}
@@ -87,6 +108,11 @@ type NotSignedInError struct{}
func (e *NotSignedInError) Error() string { return "You are not signed in" }
func (e *NotSignedInError) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingAccessToken struct{}
func (e *MissingAccessToken) Error() string { return "Missing access token" }
func (e *MissingAccessToken) HttpStatusCode() int { return http.StatusUnauthorized }
type MissingPermissionError struct{}
func (e *MissingPermissionError) Error() string {
@@ -142,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
}
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
type AccountEditNotAllowedError struct{}
func (e *AccountEditNotAllowedError) Error() string {
return "You are not allowed to edit your account"
}
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcInvalidCodeVerifierError struct{}
func (e *OidcInvalidCodeVerifierError) Error() string {
@@ -182,5 +201,151 @@ type OidcAccessDeniedError struct{}
func (e *OidcAccessDeniedError) Error() string {
return "You're not allowed to access this service"
}
func (e *OidcAccessDeniedError) HttpStatusCode() int { return http.StatusForbidden }
type OidcClientIdNotMatchingError struct{}
func (e *OidcClientIdNotMatchingError) Error() string {
return "Client id in request doesn't match client id in token"
}
func (e *OidcClientIdNotMatchingError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcNoCallbackURLError struct{}
func (e *OidcNoCallbackURLError) Error() string {
return "No callback URL provided"
}
func (e *OidcNoCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
type UiConfigDisabledError struct{}
func (e *UiConfigDisabledError) Error() string {
return "The configuration can't be changed since the UI configuration is disabled"
}
func (e *UiConfigDisabledError) HttpStatusCode() int { return http.StatusForbidden }
type InvalidUUIDError struct{}
func (e *InvalidUUIDError) Error() string {
return "Invalid UUID"
}
func (e *InvalidUUIDError) HttpStatusCode() int { return http.StatusBadRequest }
type OneTimeAccessDisabledError struct{}
func (e *OneTimeAccessDisabledError) Error() string {
return "One-time access is disabled"
}
func (e *OneTimeAccessDisabledError) HttpStatusCode() int { return http.StatusBadRequest }
type InvalidAPIKeyError struct{}
func (e *InvalidAPIKeyError) Error() string {
return "Invalid Api Key"
}
func (e *InvalidAPIKeyError) HttpStatusCode() int { return http.StatusUnauthorized }
type NoAPIKeyProvidedError struct{}
func (e *NoAPIKeyProvidedError) Error() string {
return "No API Key Provided"
}
func (e *NoAPIKeyProvidedError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyNotFoundError struct{}
func (e *APIKeyNotFoundError) Error() string {
return "API Key Not Found"
}
func (e *APIKeyNotFoundError) HttpStatusCode() int { return http.StatusUnauthorized }
type APIKeyExpirationDateError struct{}
func (e *APIKeyExpirationDateError) Error() string {
return "API Key expiration time must be in the future"
}
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
type OidcInvalidRefreshTokenError struct{}
func (e *OidcInvalidRefreshTokenError) Error() string {
return "refresh token is invalid or expired"
}
func (e *OidcInvalidRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcMissingRefreshTokenError struct{}
func (e *OidcMissingRefreshTokenError) Error() string {
return "refresh token is required"
}
func (e *OidcMissingRefreshTokenError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcMissingAuthorizationCodeError struct{}
func (e *OidcMissingAuthorizationCodeError) Error() string {
return "authorization code is required"
}
func (e *OidcMissingAuthorizationCodeError) HttpStatusCode() int {
return http.StatusBadRequest
}
type UserDisabledError struct{}
func (e *UserDisabledError) Error() string {
return "User account is disabled"
}
func (e *UserDisabledError) HttpStatusCode() int {
return http.StatusForbidden
}
type ValidationError struct {
Message string
}
func (e *ValidationError) Error() string {
return e.Message
}
func (e *ValidationError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcDeviceCodeExpiredError struct{}
func (e *OidcDeviceCodeExpiredError) Error() string {
return "device code has expired"
}
func (e *OidcDeviceCodeExpiredError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcInvalidDeviceCodeError struct{}
func (e *OidcInvalidDeviceCodeError) Error() string {
return "invalid device code"
}
func (e *OidcInvalidDeviceCodeError) HttpStatusCode() int {
return http.StatusBadRequest
}
type OidcSlowDownError struct{}
func (e *OidcSlowDownError) Error() string {
return "polling too frequently"
}
func (e *OidcSlowDownError) HttpStatusCode() int {
return http.StatusTooManyRequests
}
type OidcAuthorizationPendingError struct{}
func (e *OidcAuthorizationPendingError) Error() string {
return "authorization is still pending"
}
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -0,0 +1,6 @@
package common
// Version contains the Pocket ID version.
//
// It can be set at build time using -ldflags.
var Version = "unknown"

View File

@@ -0,0 +1,125 @@
package controller
import (
"net/http"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// swag init -g cmd/main.go -o ./docs/swagger --parseDependency
// ApiKeyController manages API keys for authenticated users
type ApiKeyController struct {
apiKeyService *service.ApiKeyService
}
// NewApiKeyController creates a new controller for API key management
// @Summary API key management controller
// @Description Initializes API endpoints for managing API keys
// @Tags API Keys
func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, apiKeyService *service.ApiKeyService) {
uc := &ApiKeyController{apiKeyService: apiKeyService}
apiKeyGroup := group.Group("/api-keys")
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
{
apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", uc.createApiKeyHandler)
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
}
}
// listApiKeysHandler godoc
// @Summary List API keys
// @Description Get a paginated list of API keys belonging to the current user
// @Tags API Keys
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
// @Router /api/api-keys [get]
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
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)
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeysDto []dto.ApiKeyDto
if err := dto.MapStructList(apiKeys, &apiKeysDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, dto.Paginated[dto.ApiKeyDto]{
Data: apiKeysDto,
Pagination: pagination,
})
}
// createApiKeyHandler godoc
// @Summary Create API key
// @Description Create a new API key for the current user
// @Tags API Keys
// @Param api_key body dto.ApiKeyCreateDto true "API key information"
// @Success 201 {object} dto.ApiKeyResponseDto "Created API key with token"
// @Router /api/api-keys [post]
func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
apiKey, token, err := c.apiKeyService.CreateApiKey(ctx.Request.Context(), userID, input)
if err != nil {
_ = ctx.Error(err)
return
}
var apiKeyDto dto.ApiKeyDto
if err := dto.MapStruct(apiKey, &apiKeyDto); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, dto.ApiKeyResponseDto{
ApiKey: apiKeyDto,
Token: token,
})
}
// revokeApiKeyHandler godoc
// @Summary Revoke API key
// @Description Revoke (delete) an existing API key by ID
// @Tags API Keys
// @Param id path string true "API Key ID"
// @Success 204 "No Content"
// @Router /api/api-keys/{id} [delete]
func (c *ApiKeyController) revokeApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID")
apiKeyID := ctx.Param("id")
if err := c.apiKeyService.RevokeApiKey(ctx.Request.Context(), userID, apiKeyID); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -1,8 +1,9 @@
package controller
import (
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -12,9 +13,13 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// NewAppConfigController creates a new controller for application configuration endpoints
// @Summary Create a new application configuration controller
// @Description Initialize routes for application configuration
// @Tags Application Configuration
func NewAppConfigController(
group *gin.RouterGroup,
jwtAuthMiddleware *middleware.JwtAuthMiddleware,
authMiddleware *middleware.AuthMiddleware,
appConfigService *service.AppConfigService,
emailService *service.EmailService,
ldapService *service.LdapService,
@@ -26,18 +31,18 @@ func NewAppConfigController(
ldapService: ldapService,
}
group.GET("/application-configuration", acc.listAppConfigHandler)
group.GET("/application-configuration/all", jwtAuthMiddleware.Add(true), acc.listAllAppConfigHandler)
group.PUT("/application-configuration", acc.updateAppConfigHandler)
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", jwtAuthMiddleware.Add(true), acc.updateLogoHandler)
group.PUT("/application-configuration/favicon", jwtAuthMiddleware.Add(true), acc.updateFaviconHandler)
group.PUT("/application-configuration/background-image", jwtAuthMiddleware.Add(true), acc.updateBackgroundImageHandler)
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", jwtAuthMiddleware.Add(true), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", jwtAuthMiddleware.Add(true), acc.syncLdapHandler)
group.POST("/application-configuration/test-email", authMiddleware.Add(), acc.testEmailHandler)
group.POST("/application-configuration/sync-ldap", authMiddleware.Add(), acc.syncLdapHandler)
}
type AppConfigController struct {
@@ -46,162 +51,253 @@ type AppConfigController struct {
ldapService *service.LdapService
}
// listAppConfigHandler godoc
// @Summary List public application configurations
// @Description Get all public application configurations
// @Tags Application Configuration
// @Accept json
// @Produce json
// @Success 200 {array} dto.PublicAppConfigVariableDto
// @Router /application-configuration [get]
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(false)
if err != nil {
c.Error(err)
return
}
configuration := acc.appConfigService.ListAppConfig(false)
var configVariablesDto []dto.PublicAppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(200, configVariablesDto)
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
Key: "uiConfigDisabled",
Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled),
Type: "boolean",
})
c.JSON(http.StatusOK, configVariablesDto)
}
// listAllAppConfigHandler godoc
// @Summary List all application configurations
// @Description Get all application configurations including private ones
// @Tags Application Configuration
// @Accept json
// @Produce json
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /application-configuration/all [get]
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
configuration, err := acc.appConfigService.ListAppConfig(true)
if err != nil {
c.Error(err)
return
}
configuration := acc.appConfigService.ListAppConfig(true)
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(configuration, &configVariablesDto); err != nil {
c.Error(err)
return
}
c.JSON(200, configVariablesDto)
}
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(input)
if err != nil {
c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, configVariablesDto)
}
// updateAppConfigHandler godoc
// @Summary Update application configurations
// @Description Update application configuration settings
// @Tags Application Configuration
// @Accept json
// @Produce json
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
// @Success 200 {array} dto.AppConfigVariableDto
// @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
savedConfigVariables, err := acc.appConfigService.UpdateAppConfig(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
var configVariablesDto []dto.AppConfigVariableDto
if err := dto.MapStructList(savedConfigVariables, &configVariablesDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, configVariablesDto)
}
// getLogoHandler godoc
// @Summary Get logo image
// @Description Get the logo image for the application
// @Tags Application Configuration
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Success 200 {file} binary "Logo image"
// @Router /api/application-configuration/logo [get]
func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
lightLogo := c.DefaultQuery("light", "true") == "true"
dbConfig := acc.appConfigService.GetDbConfig()
var imageName string
var imageType string
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName, imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
imageType = dbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
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.DbConfig.BackgroundImageType.Value
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) {
lightLogo := c.DefaultQuery("light", "true") == "true"
dbConfig := acc.appConfigService.GetDbConfig()
var imageName string
var imageType string
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
var imageName, imageType string
if lightLogo {
imageName = "logoLight"
imageType = acc.appConfigService.DbConfig.LogoLightImageType.Value
imageType = dbConfig.LogoLightImageType.Value
} else {
imageName = "logoDark"
imageType = acc.appConfigService.DbConfig.LogoDarkImageType.Value
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)
_ = c.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
if fileType != "ico" {
c.Error(&common.WrongFileTypeError{ExpectedFileType: ".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.DbConfig.BackgroundImageType.Value
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 := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, name, imageType)
imagePath := common.EnvConfig.UploadPath + "/application-images/" + name + "." + imageType
mimeType := utils.GetImageMimeType(imageType)
c.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
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)
_ = c.Error(err)
return
}
err = acc.appConfigService.UpdateImage(file, imageName, oldImageType)
err = acc.appConfigService.UpdateImage(c.Request.Context(), file, imageName, oldImageType)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// syncLdapHandler godoc
// @Summary Synchronize LDAP
// @Description Manually trigger LDAP synchronization
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/sync-ldap [post]
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
err := acc.ldapService.SyncAll()
err := acc.ldapService.SyncAll(c.Request.Context())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// testEmailHandler godoc
// @Summary Send test email
// @Description Send a test email to verify email configuration
// @Tags Application Configuration
// @Success 204 "No Content"
// @Router /api/application-configuration/test-email [post]
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
userID := c.GetString("userID")
err := acc.emailService.SendTestEmail(userID)
err := acc.emailService.SendTestEmail(c.Request.Context(), userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -11,31 +11,50 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, jwtAuthMiddleware *middleware.JwtAuthMiddleware) {
// NewAuditLogController creates a new controller for audit log management
// @Summary Audit log controller
// @Description Initializes API endpoints for accessing audit logs
// @Tags Audit Logs
func NewAuditLogController(group *gin.RouterGroup, auditLogService *service.AuditLogService, authMiddleware *middleware.AuthMiddleware) {
alc := AuditLogController{
auditLogService: auditLogService,
}
group.GET("/audit-logs", jwtAuthMiddleware.Add(false), alc.listAuditLogsForUserHandler)
group.GET("/audit-logs/all", authMiddleware.Add(), alc.listAllAuditLogsHandler)
group.GET("/audit-logs", authMiddleware.WithAdminNotRequired().Add(), alc.listAuditLogsForUserHandler)
group.GET("/audit-logs/filters/client-names", authMiddleware.Add(), alc.listClientNamesHandler)
group.GET("/audit-logs/filters/users", authMiddleware.Add(), alc.listUserNamesWithIdsHandler)
}
type AuditLogController struct {
auditLogService *service.AuditLogService
}
// listAuditLogsForUserHandler godoc
// @Summary List audit logs
// @Description Get a paginated list of audit logs for the current user
// @Tags Audit Logs
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
err := c.ShouldBindQuery(&sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
userID := c.GetString("userID")
// Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(userID, sortedPaginationRequest)
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -43,7 +62,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -53,8 +72,92 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, gin.H{
"data": logsDtos,
"pagination": pagination,
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
}
// listAllAuditLogsHandler godoc
// @Summary List all audit logs
// @Description Get a paginated list of all audit logs (admin only)
// @Tags Audit Logs
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Param filters[userId] query string false "Filter by user ID"
// @Param filters[event] query string false "Filter by event type"
// @Param filters[clientName] query string false "Filter by client name"
// @Param filters[location] query string false "Filter by location type (external or internal)"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
var filters dto.AuditLogFilterDto
if err := c.ShouldBindQuery(&filters); err != nil {
_ = c.Error(err)
return
}
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
if err != nil {
_ = c.Error(err)
return
}
var logsDtos []dto.AuditLogDto
err = dto.MapStructList(logs, &logsDtos)
if err != nil {
_ = c.Error(err)
return
}
for i, logsDto := range logsDtos {
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
logsDto.Username = logs[i].User.Username
logsDtos[i] = logsDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuditLogDto]{
Data: logsDtos,
Pagination: pagination,
})
}
// listClientNamesHandler godoc
// @Summary List client names
// @Description Get a list of all client names for audit log filtering
// @Tags Audit Logs
// @Success 200 {array} string "List of client names"
// @Router /api/audit-logs/filters/client-names [get]
func (alc *AuditLogController) listClientNamesHandler(c *gin.Context) {
names, err := alc.auditLogService.ListClientNames(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, names)
}
// listUserNamesWithIdsHandler godoc
// @Summary List users with IDs
// @Description Get a list of all usernames with their IDs for audit log filtering
// @Tags Audit Logs
// @Success 200 {object} map[string]string "Map of user IDs to usernames"
// @Router /api/audit-logs/filters/users [get]
func (alc *AuditLogController) listUserNamesWithIdsHandler(c *gin.Context) {
users, err := alc.auditLogService.ListUsernamesWithIds(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, users)
}

View File

@@ -9,69 +9,105 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewCustomClaimController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, customClaimService *service.CustomClaimService) {
// NewCustomClaimController creates a new controller for custom claim management
// @Summary Custom claim management controller
// @Description Initializes all custom claim-related API endpoints
// @Tags Custom Claims
func NewCustomClaimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, customClaimService *service.CustomClaimService) {
wkc := &CustomClaimController{customClaimService: customClaimService}
group.GET("/custom-claims/suggestions", jwtAuthMiddleware.Add(true), wkc.getSuggestionsHandler)
group.PUT("/custom-claims/user/:userId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserHandler)
group.PUT("/custom-claims/user-group/:userGroupId", jwtAuthMiddleware.Add(true), wkc.UpdateCustomClaimsForUserGroupHandler)
customClaimsGroup := group.Group("/custom-claims")
customClaimsGroup.Use(authMiddleware.Add())
{
customClaimsGroup.GET("/suggestions", wkc.getSuggestionsHandler)
customClaimsGroup.PUT("/user/:userId", wkc.UpdateCustomClaimsForUserHandler)
customClaimsGroup.PUT("/user-group/:userGroupId", wkc.UpdateCustomClaimsForUserGroupHandler)
}
}
type CustomClaimController struct {
customClaimService *service.CustomClaimService
}
// getSuggestionsHandler godoc
// @Summary Get custom claim suggestions
// @Description Get a list of suggested custom claim names
// @Tags Custom Claims
// @Produce json
// @Success 200 {array} string "List of suggested custom claim names"
// @Router /api/custom-claims/suggestions [get]
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
claims, err := ccc.customClaimService.GetSuggestions()
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// UpdateCustomClaimsForUserHandler godoc
// @Summary Update custom claims for a user
// @Description Update or create custom claims for a specific user
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userId path string true "User ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user/{userId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
userId := c.Param("userId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(userId, input)
claims, err := ccc.customClaimService.UpdateCustomClaimsForUser(c.Request.Context(), userId, input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, customClaimsDto)
}
// UpdateCustomClaimsForUserGroupHandler godoc
// @Summary Update custom claims for a user group
// @Description Update or create custom claims for a specific user group
// @Tags Custom Claims
// @Accept json
// @Produce json
// @Param userGroupId path string true "User Group ID"
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
// @Router /api/custom-claims/user-group/{userGroupId} [put]
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
userId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(userId, input)
userGroupId := c.Param("userGroupId")
claims, err := ccc.customClaimService.UpdateCustomClaimsForUserGroup(c.Request.Context(), userGroupId, input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var customClaimsDto []dto.CustomClaimDto
if err := dto.MapStructList(claims, &customClaimsDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -0,0 +1,124 @@
//go:build e2etest
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
group.POST("/test/refreshtoken", testController.signRefreshToken)
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
group.POST("/externalidp/sign", testController.externalIdPSignToken)
}
type TestController struct {
TestService *service.TestService
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
var baseURL string
if c.Request.TLS != nil {
baseURL = "https://" + c.Request.Host
} else {
baseURL = "http://" + c.Request.Host
}
skipLdap := c.Query("skip-ldap") == "true"
if err := tc.TestService.ResetDatabase(); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if !skipLdap {
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
jwks, err := tc.TestService.GetExternalIdPJWKS()
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, jwks)
}
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
var input struct {
Aud string `json:"aud"`
Iss string `json:"iss"`
Sub string `json:"sub"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}
func (tc *TestController) signRefreshToken(c *gin.Context) {
var input struct {
UserID string `json:"user"`
ClientID string `json:"client"`
RefreshToken string `json:"rt"`
}
err := c.ShouldBindJSON(&input)
if err != nil {
_ = c.Error(err)
return
}
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
if err != nil {
_ = c.Error(err)
return
}
c.Writer.WriteString(token)
}

View File

@@ -0,0 +1,29 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
)
// NewHealthzController creates a new controller for the healthcheck endpoints
// @Summary Healthcheck controller
// @Description Initializes healthcheck endpoints
// @Tags Health
func NewHealthzController(r *gin.Engine) {
hc := &HealthzController{}
r.GET("/healthz", hc.healthzHandler)
}
type HealthzController struct{}
// healthzHandler godoc
// @Summary Responds to healthchecks
// @Description Responds with a successful status code to healthcheck requests
// @Tags Health
// @Success 204 ""
// @Router /healthz [get]
func (hc *HealthzController) healthzHandler(c *gin.Context) {
c.Status(http.StatusNoContent)
}

View File

@@ -1,37 +1,62 @@
package controller
import (
"errors"
"log"
"net/http"
"net/url"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
)
func NewOidcController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
// NewOidcController creates a new controller for OIDC related endpoints
// @Summary OIDC controller
// @Description Initializes all OIDC-related API endpoints for authentication and client management
// @Tags OIDC
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
group.POST("/oidc/authorize", jwtAuthMiddleware.Add(false), oc.authorizeHandler)
group.POST("/oidc/authorization-required", jwtAuthMiddleware.Add(false), oc.authorizationConfirmationRequiredHandler)
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
group.POST("/oidc/token", oc.createTokensHandler)
group.GET("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/userinfo", oc.userInfoHandler)
group.POST("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
group.GET("/oidc/end-session", authMiddleware.WithAdminNotRequired().WithSuccessOptional().Add(), oc.EndSessionHandler)
group.POST("/oidc/introspect", oc.introspectTokenHandler)
group.GET("/oidc/clients", jwtAuthMiddleware.Add(true), oc.listClientsHandler)
group.POST("/oidc/clients", jwtAuthMiddleware.Add(true), oc.createClientHandler)
group.GET("/oidc/clients/:id", oc.getClientHandler)
group.PUT("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.updateClientHandler)
group.DELETE("/oidc/clients/:id", jwtAuthMiddleware.Add(true), oc.deleteClientHandler)
group.GET("/oidc/clients", authMiddleware.Add(), oc.listClientsHandler)
group.POST("/oidc/clients", authMiddleware.Add(), oc.createClientHandler)
group.GET("/oidc/clients/:id", authMiddleware.Add(), oc.getClientHandler)
group.GET("/oidc/clients/:id/meta", oc.getClientMetaDataHandler)
group.PUT("/oidc/clients/:id", authMiddleware.Add(), oc.updateClientHandler)
group.DELETE("/oidc/clients/:id", authMiddleware.Add(), oc.deleteClientHandler)
group.PUT("/oidc/clients/:id/allowed-user-groups", jwtAuthMiddleware.Add(true), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", jwtAuthMiddleware.Add(true), oc.createClientSecretHandler)
group.PUT("/oidc/clients/:id/allowed-user-groups", authMiddleware.Add(), oc.updateAllowedUserGroupsHandler)
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", jwtAuthMiddleware.Add(true), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
}
type OidcController struct {
@@ -39,16 +64,25 @@ type OidcController struct {
jwtService *service.JwtService
}
// authorizeHandler godoc
// @Summary Authorize OIDC client
// @Description Start the OIDC authorization process for a client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
// @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
code, callbackURL, err := oc.oidcService.Authorize(input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -60,241 +94,687 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
c.JSON(http.StatusOK, response)
}
// authorizationConfirmationRequiredHandler godoc
// @Summary Check if authorization confirmation is required
// @Description Check if the user needs to confirm authorization for the client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
// @Router /api/oidc/authorization-required [post]
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
var input dto.AuthorizationRequiredDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(input.ClientID, c.GetString("userID"), input.Scope)
hasAuthorizedClient, err := oc.oidcService.HasAuthorizedClient(c.Request.Context(), input.ClientID, c.GetString("userID"), input.Scope)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"authorizationRequired": !hasAuthorizedClient})
}
// createTokensHandler godoc
// @Summary Create OIDC tokens
// @Description Exchange authorization code or refresh token for access tokens
// @Tags OIDC
// @Produce json
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Disable cors for this endpoint
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
var input dto.OidcCreateTokensDto
if err := c.ShouldBind(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
clientID := input.ClientID
clientSecret := input.ClientSecret
// Validate that code is provided for authorization_code grant type
if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
_ = c.Error(&common.OidcMissingAuthorizationCodeError{})
return
}
// Validate that refresh_token is provided for refresh_token grant type
if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
_ = c.Error(&common.OidcMissingRefreshTokenError{})
return
}
// Client id and secret can also be passed over the Authorization header
if clientID == "" && clientSecret == "" {
clientID, clientSecret, _ = c.Request.BasicAuth()
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
idToken, accessToken, err := oc.oidcService.CreateTokens(input.Code, input.GrantType, clientID, clientSecret, input.CodeVerifier)
if err != nil {
c.Error(err)
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "authorization_pending",
})
return
case errors.Is(err, &common.OidcSlowDownError{}):
c.JSON(http.StatusBadRequest, gin.H{
"error": "slow_down",
})
return
case err != nil:
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"id_token": idToken, "access_token": accessToken, "token_type": "Bearer"})
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
AccessToken: tokens.AccessToken,
TokenType: "Bearer",
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
IdToken: tokens.IdToken, // May be empty
RefreshToken: tokens.RefreshToken, // May be empty
})
}
// userInfoHandler godoc
// @Summary Get user information
// @Description Get user information based on the access token
// @Tags OIDC
// @Accept json
// @Produce json
// @Success 200 {object} object "User claims based on requested scopes"
// @Security OAuth2AccessToken
// @Router /api/oidc/userinfo [get]
func (oc *OidcController) userInfoHandler(c *gin.Context) {
token := strings.Split(c.GetHeader("Authorization"), " ")[1]
jwtClaims, err := oc.jwtService.VerifyOauthAccessToken(token)
if err != nil {
c.Error(err)
_, authToken, ok := strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || authToken == "" {
_ = c.Error(&common.MissingAccessToken{})
return
}
userID := jwtClaims.Subject
clientId := jwtClaims.Audience[0]
claims, err := oc.oidcService.GetUserClaimsForClient(userID, clientId)
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
userID, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
clientID, ok := token.Audience()
if !ok || len(clientID) != 1 {
_ = c.Error(&common.TokenInvalidError{})
return
}
claims, err := oc.oidcService.GetUserClaimsForClient(c.Request.Context(), userID, clientID[0])
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, claims)
}
// EndSessionHandler godoc
// @Summary End OIDC session
// @Description End user session and handle OIDC logout
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Param id_token_hint query string false "ID token"
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
// @Param state query string false "State parameter to include in the redirect"
// @Success 302 "Redirect to post-logout URL or application logout page"
// @Router /api/oidc/end-session [get]
func (oc *OidcController) EndSessionHandler(c *gin.Context) {
var input dto.OidcLogoutDto
// Bind query parameters to the struct
switch c.Request.Method {
case http.MethodGet:
if err := c.ShouldBindQuery(&input); err != nil {
_ = c.Error(err)
return
}
case http.MethodPost:
// Bind form parameters to the struct
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
}
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)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return
}
// The validation was successful, so we can log out and redirect the user to the callback URL without confirmation
cookie.AddAccessTokenCookie(c, 0, "")
logoutCallbackURL, _ := url.Parse(callbackURL)
if input.State != "" {
q := logoutCallbackURL.Query()
q.Set("state", input.State)
logoutCallbackURL.RawQuery = q.Encode()
}
c.Redirect(http.StatusFound, logoutCallbackURL.String())
}
// EndSessionHandler godoc (POST method)
// @Summary End OIDC session (POST method)
// @Description End user session and handle OIDC logout using POST
// @Tags OIDC
// @Accept application/x-www-form-urlencoded
// @Produce html
// @Param id_token_hint formData string false "ID token"
// @Param post_logout_redirect_uri formData string false "URL to redirect to after logout"
// @Param state formData string false "State parameter to include in the redirect"
// @Success 302 "Redirect to post-logout URL or application logout page"
// @Router /api/oidc/end-session [post]
func (oc *OidcController) EndSessionHandlerPost(c *gin.Context) {
// Implementation is the same as GET
}
// introspectToken godoc
// @Summary Introspect OIDC tokens
// @Description Pass an access_token to verify if it is considered valid.
// @Tags OIDC
// @Produce json
// @Param token formData string true "The token to be introspected."
// @Success 200 {object} dto.OidcIntrospectionResponseDto "Response with the introspection result."
// @Router /api/oidc/introspect [post]
func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
var input dto.OidcIntrospectDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret have to be passed over the Authorization header. This kind of
// authentication allows us to keep the endpoint protected (since it could be used to
// find valid tokens) while still allowing it to be used by an application that is
// supposed to interact with our IdP (since that needs to have a client_id
// and client_secret anyway).
var (
creds service.ClientAuthCredentials
ok bool
)
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
if !ok {
// If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request)
if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer
}
}
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// getClientMetaDataHandler godoc
// @Summary Get client metadata
// @Description Get OIDC client metadata for discovery and configuration
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientMetaDataDto "Client metadata"
// @Router /api/oidc/clients/{id}/meta [get]
func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
if err != nil {
_ = c.Error(err)
return
}
clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
_ = c.Error(err)
}
// getClientHandler godoc
// @Summary Get OIDC client
// @Description Get detailed information about an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
// @Router /api/oidc/clients/{id} [get]
func (oc *OidcController) getClientHandler(c *gin.Context) {
clientId := c.Param("id")
client, err := oc.oidcService.GetClient(clientId)
client, err := oc.oidcService.GetClient(c.Request.Context(), clientId)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
// Return a different DTO based on the user's role
if c.GetBool("userIsAdmin") {
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
} else {
clientDto := dto.PublicOidcClientDto{}
err = dto.MapStruct(client, &clientDto)
if err == nil {
c.JSON(http.StatusOK, clientDto)
return
}
}
c.Error(err)
}
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
}
clients, pagination, err := oc.oidcService.ListClients(searchTerm, sortedPaginationRequest)
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
err = dto.MapStruct(client, &clientDto)
if err != nil {
c.Error(err)
return
}
var clientsDto []dto.OidcClientDto
if err := dto.MapStructList(clients, &clientsDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": clientsDto,
"pagination": pagination,
})
}
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(input, c.GetString("userID"))
if err != nil {
c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
return
}
c.JSON(http.StatusCreated, clientDto)
}
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Param("id"))
if err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Param("id"), input)
if err != nil {
c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Param("id"))
// listClientsHandler godoc
// @Summary List OIDC clients
// @Description Get a paginated list of OIDC clients with optional search and sorting
// @Tags OIDC
// @Param search query string false "Search term to filter clients by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
// @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
// Map the user groups to DTOs
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
for i, client := range clients {
var clientDto dto.OidcClientWithAllowedGroupsCountDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil {
_ = c.Error(err)
return
}
clientsDto[i] = clientDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
Data: clientsDto,
Pagination: pagination,
})
}
// createClientHandler godoc
// @Summary Create OIDC client
// @Description Create a new OIDC client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param client body dto.OidcClientCreateDto true "Client information"
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
// @Router /api/oidc/clients [post]
func (oc *OidcController) createClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
client, err := oc.oidcService.CreateClient(c.Request.Context(), input, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, clientDto)
}
// deleteClientHandler godoc
// @Summary Delete OIDC client
// @Description Delete an OIDC client by ID
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id} [delete]
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// updateClientHandler godoc
// @Summary Update OIDC client
// @Description Update an existing OIDC client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto 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
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
client, err := oc.oidcService.UpdateClient(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
var clientDto dto.OidcClientWithAllowedUserGroupsDto
if err := dto.MapStruct(client, &clientDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, clientDto)
}
// createClientSecretHandler godoc
// @Summary Create client secret
// @Description Generate a new secret for an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} object "{ \"secret\": \"string\" }"
// @Router /api/oidc/clients/{id}/secret [post]
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"secret": secret})
}
// getClientLogoHandler godoc
// @Summary Get client logo
// @Description Get the logo image for an OIDC client
// @Tags OIDC
// @Produce image/png
// @Produce image/jpeg
// @Produce image/svg+xml
// @Param id path string true "Client ID"
// @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.Param("id"))
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType)
c.File(imagePath)
}
// updateClientLogoHandler godoc
// @Summary Update client logo
// @Description Upload or update the logo for an OIDC client
// @Tags OIDC
// @Accept multipart/form-data
// @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
err = oc.oidcService.UpdateClientLogo(c.Param("id"), file)
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// deleteClientLogoHandler godoc
// @Summary Delete client logo
// @Description Delete the logo for an OIDC client
// @Tags OIDC
// @Param id path string true "Client ID"
// @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Param("id"))
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// updateAllowedUserGroupsHandler godoc
// @Summary Update allowed user groups
// @Description Update the user groups allowed to access an OIDC client
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "Client ID"
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
// @Success 200 {object} dto.OidcClientDto "Updated client"
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
var input dto.OidcUpdateAllowedUserGroupsDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Param("id"), input)
oidcClient, err := oc.oidcService.UpdateAllowedUserGroups(c.Request.Context(), c.Param("id"), input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var oidcClientDto dto.OidcClientDto
if err := dto.MapStruct(oidcClient, &oidcClientDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, oidcClientDto)
}
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
var input dto.OidcDeviceAuthorizationRequestDto
if err := c.ShouldBind(&input); err != nil {
_ = c.Error(err)
return
}
// Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
}
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, response)
}
// listOwnAuthorizedClientsHandler godoc
// @Summary List authorized clients for current user
// @Description Get a paginated list of OIDC clients that the current user has authorized
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID)
}
// listAuthorizedClientsHandler godoc
// @Summary List authorized clients for a user
// @Description Get a paginated list of OIDC clients that a specific user has authorized
// @Tags OIDC
// @Param id path string true "User ID"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id")
oc.listAuthorizedClients(c, userID)
}
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
// Map the clients to DTOs
var authorizedClientsDto []dto.AuthorizedOidcClientDto
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
Data: authorizedClientsDto,
Pagination: pagination,
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
// Get IP address and user agent from the request context
ipAddress := c.ClientIP()
userAgent := c.Request.UserAgent()
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
userCode := c.Query("code")
if userCode == "" {
_ = c.Error(&common.ValidationError{Message: "code is required"})
return
}
deviceCodeInfo, err := oc.oidcService.GetDeviceCodeInfo(c.Request.Context(), userCode, c.GetString("userID"))
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, deviceCodeInfo)
}
// getClientPreviewHandler godoc
// @Summary Preview OIDC client data for user
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Param userId path string true "User ID to preview data for"
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
// @Security BearerAuth
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
clientID := c.Param("id")
userID := c.Param("userId")
scopes := c.Query("scopes")
if clientID == "" {
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
return
}
if userID == "" {
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
return
}
if scopes == "" {
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
return
}
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, preview)
}

View File

@@ -1,42 +0,0 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
func NewTestController(group *gin.RouterGroup, testService *service.TestService) {
testController := &TestController{TestService: testService}
group.POST("/test/reset", testController.resetAndSeedHandler)
}
type TestController struct {
TestService *service.TestService
}
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
if err := tc.TestService.ResetDatabase(); err != nil {
c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(); err != nil {
c.Error(err)
return
}
if err := tc.TestService.SeedDatabase(); err != nil {
c.Error(err)
return
}
if err := tc.TestService.ResetAppConfig(); err != nil {
c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -2,13 +2,11 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
"github.com/pocket-id/pocket-id/backend/internal/service"
@@ -16,24 +14,41 @@ import (
"golang.org/x/time/rate"
)
func NewUserController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
// NewUserController creates a new controller for user management endpoints
// @Summary User management controller
// @Description Initializes all user-related API endpoints
// @Tags Users
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, appConfigService *service.AppConfigService) {
uc := UserController{
userService: userService,
appConfigService: appConfigService,
}
group.GET("/users", jwtAuthMiddleware.Add(true), uc.listUsersHandler)
group.GET("/users/me", jwtAuthMiddleware.Add(false), uc.getCurrentUserHandler)
group.GET("/users/:id", jwtAuthMiddleware.Add(true), uc.getUserHandler)
group.POST("/users", jwtAuthMiddleware.Add(true), uc.createUserHandler)
group.PUT("/users/:id", jwtAuthMiddleware.Add(true), uc.updateUserHandler)
group.PUT("/users/me", jwtAuthMiddleware.Add(false), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", jwtAuthMiddleware.Add(true), uc.deleteUserHandler)
group.GET("/users", authMiddleware.Add(), uc.listUsersHandler)
group.GET("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.getCurrentUserHandler)
group.GET("/users/:id", authMiddleware.Add(), uc.getUserHandler)
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
group.POST("/users/:id/one-time-access-token", jwtAuthMiddleware.Add(true), uc.createOneTimeAccessTokenHandler)
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
group.GET("/users/:id/profile-picture.png", uc.getUserProfilePictureHandler)
group.PUT("/users/:id/profile-picture", authMiddleware.Add(), uc.updateUserProfilePictureHandler)
group.PUT("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserProfilePictureHandler)
group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
group.POST("/users/:id/one-time-access-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.requestOneTimeAccessEmailHandler)
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)
}
type UserController struct {
@@ -41,183 +56,450 @@ type UserController struct {
appConfigService *service.AppConfigService
}
// getUserGroupsHandler godoc
// @Summary Get user groups
// @Description Retrieve all groups a specific user belongs to
// @Tags Users,User Groups
// @Param id path string true "User ID"
// @Success 200 {array} dto.UserGroupDtoWithUsers
// @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
groups, err := uc.userService.GetUserGroups(c.Request.Context(), userID)
if err != nil {
_ = c.Error(err)
return
}
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupsDto)
}
// listUsersHandler godoc
// @Summary List users
// @Description Get a paginated list of users with optional search and sorting
// @Tags Users
// @Param search query string false "Search term to filter users"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserDto]
// @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
users, pagination, err := uc.userService.ListUsers(searchTerm, sortedPaginationRequest)
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var usersDto []dto.UserDto
if err := dto.MapStructList(users, &usersDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{
"data": usersDto,
"pagination": pagination,
c.JSON(http.StatusOK, dto.Paginated[dto.UserDto]{
Data: usersDto,
Pagination: pagination,
})
}
// getUserHandler godoc
// @Summary Get user by ID
// @Description Retrieve detailed information about a specific user
// @Tags Users
// @Param id path string true "User ID"
// @Success 200 {object} dto.UserDto
// @Router /api/users/{id} [get]
func (uc *UserController) getUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.Param("id"))
user, err := uc.userService.GetUser(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
// getCurrentUserHandler godoc
// @Summary Get current user
// @Description Retrieve information about the currently authenticated user
// @Tags Users
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [get]
func (uc *UserController) getCurrentUserHandler(c *gin.Context) {
user, err := uc.userService.GetUser(c.GetString("userID"))
user, err := uc.userService.GetUser(c.Request.Context(), c.GetString("userID"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
// deleteUserHandler godoc
// @Summary Delete user
// @Description Delete a specific user by ID
// @Tags Users
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /api/users/{id} [delete]
func (uc *UserController) deleteUserHandler(c *gin.Context) {
if err := uc.userService.DeleteUser(c.Param("id")); err != nil {
c.Error(err)
if err := uc.userService.DeleteUser(c.Request.Context(), c.Param("id"), false); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// createUserHandler godoc
// @Summary Create user
// @Description Create a new user
// @Tags Users
// @Param user body dto.UserCreateDto true "User information"
// @Success 201 {object} dto.UserDto
// @Router /api/users [post]
func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
user, err := uc.userService.CreateUser(input)
user, err := uc.userService.CreateUser(c.Request.Context(), input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, userDto)
}
// updateUserHandler godoc
// @Summary Update user
// @Description Update an existing user by ID
// @Tags Users
// @Param id path string true "User ID"
// @Param user body dto.UserCreateDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/users/{id} [put]
func (uc *UserController) updateUserHandler(c *gin.Context) {
uc.updateUser(c, false)
}
// updateCurrentUserHandler godoc
// @Summary Update current user
// @Description Update the currently authenticated user's information
// @Tags Users
// @Param user body dto.UserCreateDto true "User information"
// @Success 200 {object} dto.UserDto
// @Router /api/users/me [put]
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
if uc.appConfigService.DbConfig.AllowOwnAccountEdit.Value != "true" {
c.Error(&common.AccountEditNotAllowedError{})
return
}
uc.updateUser(c, true)
}
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context) {
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
return
}
// getUserProfilePictureHandler godoc
// @Summary Get user profile picture
// @Description Retrieve a specific user's profile picture
// @Tags Users
// @Produce image/png
// @Param id path string true "User ID"
// @Success 200 {file} binary "PNG image"
// @Router /api/users/{id}/profile-picture.png [get]
func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
token, err := uc.userService.CreateOneTimeAccessToken(input.UserID, input.ExpiresAt)
picture, size, err := uc.userService.GetProfilePicture(c.Request.Context(), userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
if picture != nil {
defer picture.Close()
}
c.JSON(http.StatusCreated, gin.H{"token": token})
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
}
func (uc *UserController) requestOneTimeAccessEmailHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
// updateUserProfilePictureHandler godoc
// @Summary Update user profile picture
// @Description Update a specific user's profile picture
// @Tags Users
// @Accept multipart/form-data
// @Produce json
// @Param id path string true "User ID"
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
// @Success 204 "No Content"
// @Router /api/users/{id}/profile-picture [put]
func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
fileHeader, err := c.FormFile("file")
if err != nil {
_ = c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmail(input.Email, input.RedirectPath)
file, err := fileHeader.Open()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// updateCurrentUserProfilePictureHandler godoc
// @Summary Update current user's profile picture
// @Description Update the currently authenticated user's profile picture
// @Tags Users
// @Accept multipart/form-data
// @Produce json
// @Param file formData file true "Profile picture image file (PNG, JPG, or JPEG)"
// @Success 204 "No Content"
// @Router /api/users/me/profile-picture [put]
func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
fileHeader, err := c.FormFile("file")
if err != nil {
_ = c.Error(err)
return
}
file, err := fileHeader.Open()
if err != nil {
_ = c.Error(err)
return
}
defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
var input dto.OneTimeAccessTokenCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
if own {
input.UserID = c.GetString("userID")
}
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, gin.H{"token": token})
}
// createOwnOneTimeAccessTokenHandler godoc
// @Summary Create one-time access token for current user
// @Description Generate a one-time access token for the currently authenticated user
// @Tags Users
// @Param id path string true "User ID"
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
// @Success 201 {object} object "{ \"token\": \"string\" }"
// @Router /api/users/{id}/one-time-access-token [post]
func (uc *UserController) createOwnOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, true)
}
// createAdminOneTimeAccessTokenHandler godoc
// @Summary Create one-time access token for user (admin)
// @Description Generate a one-time access token for a specific user (admin only)
// @Tags Users
// @Param id path string true "User ID"
// @Param body body dto.OneTimeAccessTokenCreateDto true "Token options"
// @Success 201 {object} object "{ \"token\": \"string\" }"
// @Router /api/users/{id}/one-time-access-token [post]
func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
uc.createOneTimeAccessTokenHandler(c, false)
}
// RequestOneTimeAccessEmailAsUnauthenticatedUserHandler godoc
// @Summary Request one-time access email
// @Description Request a one-time access email for unauthenticated users
// @Tags Users
// @Accept json
// @Produce json
// @Param body body dto.OneTimeAccessEmailAsUnauthenticatedUserDto true "Email request information"
// @Success 204 "No Content"
// @Router /api/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// RequestOneTimeAccessEmailAsAdminHandler godoc
// @Summary Request one-time access email (admin)
// @Description Request a one-time access email for a specific user (admin only)
// @Tags Users
// @Accept json
// @Produce json
// @Param id path string true "User ID"
// @Param body body dto.OneTimeAccessEmailAsAdminDto true "Email request options"
// @Success 204 "No Content"
// @Router /api/users/{id}/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsAdminDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
userID := c.Param("id")
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// exchangeOneTimeAccessTokenHandler godoc
// @Summary Exchange one-time access token
// @Description Exchange a one-time access token for a session token
// @Tags Users
// @Param token path string true "One-time access token"
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Param("token"), c.ClientIP(), c.Request.UserAgent())
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// getSetupAccessTokenHandler godoc
// @Summary Setup initial admin
// @Description Generate setup access token for initial admin user configuration
// @Tags Users
// @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()
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(uc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
}
// updateUserGroups godoc
// @Summary Update user groups
// @Description Update the groups a specific user belongs to
// @Tags Users
// @Param id path string true "User ID"
// @Param groups body dto.UserUpdateUserGroupDto true "User group IDs"
// @Success 200 {object} dto.UserDto
// @Router /api/users/{id}/user-groups [put]
func (uc *UserController) updateUserGroups(c *gin.Context) {
var input dto.UserUpdateUserGroupDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
user, err := uc.userService.UpdateUserGroups(c.Request.Context(), c.Param("id"), input.UserGroupIds)
if err != nil {
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, 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 {
c.Error(err)
_ = c.Error(err)
return
}
@@ -228,17 +510,54 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
userID = c.Param("id")
}
user, err := uc.userService.UpdateUser(userID, input, updateOwnUser, false)
user, err := uc.userService.UpdateUser(c.Request.Context(), userID, input, updateOwnUser, false)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userDto)
}
// resetUserProfilePictureHandler godoc
// @Summary Reset user profile picture
// @Description Reset a specific user's profile picture to the default
// @Tags Users
// @Produce json
// @Param id path string true "User ID"
// @Success 204 "No Content"
// @Router /api/users/{id}/profile-picture [delete]
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// resetCurrentUserProfilePictureHandler godoc
// @Summary Reset current user's profile picture
// @Description Reset the currently authenticated user's profile picture to the default
// @Tags Users
// @Produce json
// @Success 204 "No Content"
// @Router /api/users/me/profile-picture [delete]
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}

View File

@@ -10,144 +10,212 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func NewUserGroupController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, userGroupService *service.UserGroupService) {
// NewUserGroupController creates a new controller for user group management
// @Summary User group management controller
// @Description Initializes all user group-related API endpoints
// @Tags User Groups
func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, userGroupService *service.UserGroupService) {
ugc := UserGroupController{
UserGroupService: userGroupService,
}
group.GET("/user-groups", jwtAuthMiddleware.Add(true), ugc.list)
group.GET("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.get)
group.POST("/user-groups", jwtAuthMiddleware.Add(true), ugc.create)
group.PUT("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.update)
group.DELETE("/user-groups/:id", jwtAuthMiddleware.Add(true), ugc.delete)
group.PUT("/user-groups/:id/users", jwtAuthMiddleware.Add(true), ugc.updateUsers)
userGroupsGroup := group.Group("/user-groups")
userGroupsGroup.Use(authMiddleware.Add())
{
userGroupsGroup.GET("", ugc.list)
userGroupsGroup.GET("/:id", ugc.get)
userGroupsGroup.POST("", ugc.create)
userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
}
}
type UserGroupController struct {
UserGroupService *service.UserGroupService
}
// list godoc
// @Summary List user groups
// @Description Get a paginated list of user groups with optional search and sorting
// @Tags User Groups
// @Param search query string false "Search term to filter user groups by name"
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
ctx := c.Request.Context()
searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
groups, pagination, err := ugc.UserGroupService.List(searchTerm, sortedPaginationRequest)
groups, pagination, err := ugc.UserGroupService.List(ctx, searchTerm, sortedPaginationRequest)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
// Map the user groups to DTOs. The user count can't be mapped directly, so we have to do it manually.
// Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(group.ID)
groupDto.UserCount, err = ugc.UserGroupService.GetUserCountOfGroup(ctx, group.ID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, gin.H{
"data": groupsDto,
"pagination": pagination,
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
Data: groupsDto,
Pagination: pagination,
})
}
// get godoc
// @Summary Get user group by ID
// @Description Retrieve detailed information about a specific user group including its users
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Param("id"))
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
// create godoc
// @Summary Create user group
// @Description Create a new user group
// @Tags User Groups
// @Accept json
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Create(input)
group, err := ugc.UserGroupService.Create(c.Request.Context(), input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusCreated, groupDto)
}
// update godoc
// @Summary Update user group
// @Description Update an existing user group by ID
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.Update(c.Param("id"), input, false)
group, err := ugc.UserGroupService.Update(c.Request.Context(), c.Param("id"), input)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, groupDto)
}
// delete godoc
// @Summary Delete user group
// @Description Delete a specific user group by ID
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 204 "No Content"
// @Router /api/user-groups/{id} [delete]
func (ugc *UserGroupController) delete(c *gin.Context) {
if err := ugc.UserGroupService.Delete(c.Param("id")); err != nil {
c.Error(err)
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// updateUsers godoc
// @Summary Update users in a group
// @Description Update the list of users belonging to a specific user group
// @Tags User Groups
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
group, err := ugc.UserGroupService.UpdateUsers(c.Param("id"), input)
group, err := ugc.UserGroupService.UpdateUsers(c.Request.Context(), c.Param("id"), input.UserIDs)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -2,7 +2,6 @@ package controller
import (
"net/http"
"strconv"
"time"
"github.com/go-webauthn/webauthn/protocol"
@@ -16,19 +15,19 @@ import (
"golang.org/x/time/rate"
)
func NewWebauthnController(group *gin.RouterGroup, jwtAuthMiddleware *middleware.JwtAuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, webauthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
wc := &WebauthnController{webAuthnService: webauthnService, appConfigService: appConfigService}
group.GET("/webauthn/register/start", jwtAuthMiddleware.Add(false), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", jwtAuthMiddleware.Add(false), wc.verifyRegistrationHandler)
group.GET("/webauthn/register/start", authMiddleware.WithAdminNotRequired().Add(), wc.beginRegistrationHandler)
group.POST("/webauthn/register/finish", authMiddleware.WithAdminNotRequired().Add(), wc.verifyRegistrationHandler)
group.GET("/webauthn/login/start", wc.beginLoginHandler)
group.POST("/webauthn/login/finish", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.verifyLoginHandler)
group.POST("/webauthn/logout", jwtAuthMiddleware.Add(false), wc.logoutHandler)
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
group.GET("/webauthn/credentials", jwtAuthMiddleware.Add(false), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", jwtAuthMiddleware.Add(false), wc.deleteCredentialHandler)
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)
}
type WebauthnController struct {
@@ -38,9 +37,9 @@ type WebauthnController struct {
func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
userID := c.GetString("userID")
options, err := wc.webAuthnService.BeginRegistration(userID)
options, err := wc.webAuthnService.BeginRegistration(c.Request.Context(), userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -51,20 +50,20 @@ func (wc *WebauthnController) beginRegistrationHandler(c *gin.Context) {
func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
c.Error(&common.MissingSessionIdError{})
_ = c.Error(&common.MissingSessionIdError{})
return
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(sessionID, userID, c.Request)
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -72,9 +71,9 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
}
func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
options, err := wc.webAuthnService.BeginLogin()
options, err := wc.webAuthnService.BeginLogin(c.Request.Context())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -85,30 +84,29 @@ func (wc *WebauthnController) beginLoginHandler(c *gin.Context) {
func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
c.Error(&common.MissingSessionIdError{})
_ = c.Error(&common.MissingSessionIdError{})
return
}
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
user, token, err := wc.webAuthnService.VerifyLogin(sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
user, token, err := wc.webAuthnService.VerifyLogin(c.Request.Context(), sessionID, credentialAssertionData, c.ClientIP(), c.Request.UserAgent())
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var userDto dto.UserDto
if err := dto.MapStruct(user, &userDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
sessionDurationInMinutesParsed, _ := strconv.Atoi(wc.appConfigService.DbConfig.SessionDuration.Value)
maxAge := sessionDurationInMinutesParsed * 60
maxAge := int(wc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
cookie.AddAccessTokenCookie(c, maxAge, token)
c.JSON(http.StatusOK, userDto)
@@ -116,15 +114,15 @@ func (wc *WebauthnController) verifyLoginHandler(c *gin.Context) {
func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
userID := c.GetString("userID")
credentials, err := wc.webAuthnService.ListCredentials(userID)
credentials, err := wc.webAuthnService.ListCredentials(c.Request.Context(), userID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var credentialDtos []dto.WebauthnCredentialDto
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -135,9 +133,9 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
err := wc.webAuthnService.DeleteCredential(userID, credentialID)
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
@@ -150,19 +148,19 @@ func (wc *WebauthnController) updateCredentialHandler(c *gin.Context) {
var input dto.WebauthnCredentialUpdateDto
if err := c.ShouldBindJSON(&input); err != nil {
c.Error(err)
_ = c.Error(err)
return
}
credential, err := wc.webAuthnService.UpdateCredential(userID, credentialID, input.Name)
credential, err := wc.webAuthnService.UpdateCredential(c.Request.Context(), userID, credentialID, input.Name)
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
var credentialDto dto.WebauthnCredentialDto
if err := dto.MapStruct(credential, &credentialDto); err != nil {
c.Error(err)
_ = c.Error(err)
return
}

View File

@@ -1,46 +1,88 @@
package controller
import (
"encoding/json"
"fmt"
"log"
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// NewWellKnownController creates a new controller for OIDC discovery endpoints
// @Summary OIDC Discovery controller
// @Description Initializes OIDC discovery and JWKS endpoints
// @Tags Well Known
func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtService) {
wkc := &WellKnownController{jwtService: jwtService}
// Pre-compute the OIDC configuration document, which is static
var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
}
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
group.GET("/.well-known/openid-configuration", wkc.openIDConfigurationHandler)
}
type WellKnownController struct {
jwtService *service.JwtService
oidcConfig []byte
}
// jwksHandler godoc
// @Summary Get JSON Web Key Set (JWKS)
// @Description Returns the JSON Web Key Set used for token verification
// @Tags Well Known
// @Produce json
// @Success 200 {object} object "{ \"keys\": []interface{} }"
// @Router /.well-known/jwks.json [get]
func (wkc *WellKnownController) jwksHandler(c *gin.Context) {
jwk, err := wkc.jwtService.GetJWK()
jwks, err := wkc.jwtService.GetPublicJWKSAsJSON()
if err != nil {
c.Error(err)
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, gin.H{"keys": []interface{}{jwk}})
c.Data(http.StatusOK, "application/json; charset=utf-8", jwks)
}
// openIDConfigurationHandler godoc
// @Summary Get OpenID Connect discovery configuration
// @Description Returns the OpenID Connect discovery document with endpoints and capabilities
// @Tags Well Known
// @Success 200 {object} object "OpenID Connect configuration"
// @Router /.well-known/openid-configuration [get]
func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
c.Data(http.StatusOK, "application/json; charset=utf-8", wkc.oidcConfig)
}
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL
config := map[string]interface{}{
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",
"scopes_supported": []string{"openid", "profile", "email"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username"},
"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{"RS256"},
"id_token_signing_alg_values_supported": []string{alg.String()},
}
c.JSON(http.StatusOK, config)
return json.Marshal(config)
}

View File

@@ -0,0 +1,26 @@
package dto
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ApiKeyCreateDto struct {
Name string `json:"name" binding:"required,min=3,max=50"`
Description string `json:"description"`
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
}
type ApiKeyDto struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
ExpiresAt datatype.DateTime `json:"expiresAt"`
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
CreatedAt datatype.DateTime `json:"createdAt"`
ExpirationEmailSent bool `json:"expirationEmailSent"`
}
type ApiKeyResponseDto struct {
ApiKey ApiKeyDto `json:"apiKey"`
Token string `json:"token"`
}

View File

@@ -12,31 +12,40 @@ type AppConfigVariableDto struct {
}
type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
SmtHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"`
LdapBindDn string `json:"ldapBindDn"`
LdapBindPassword string `json:"ldapBindPassword"`
LdapBase string `json:"ldapBase"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
EmailOneTimeAccessEnabled string `json:"emailOneTimeAccessEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
AppName string `json:"appName" binding:"required,min=1,max=30"`
SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
SmtpUser string `json:"smtpUser"`
SmtpPassword string `json:"smtpPassword"`
SmtpTls string `json:"smtpTls" binding:"required,oneof=none starttls tls"`
SmtpSkipCertVerify string `json:"smtpSkipCertVerify"`
LdapEnabled string `json:"ldapEnabled" binding:"required"`
LdapUrl string `json:"ldapUrl"`
LdapBindDn string `json:"ldapBindDn"`
LdapBindPassword string `json:"ldapBindPassword"`
LdapBase string `json:"ldapBase"`
LdapUserSearchFilter string `json:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter string `json:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify string `json:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier string `json:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername string `json:"ldapAttributeUserUsername"`
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
EmailLoginNotificationEnabled string `json:"emailLoginNotificationEnabled" binding:"required"`
EmailApiKeyExpirationEnabled string `json:"emailApiKeyExpirationEnabled" binding:"required"`
}

View File

@@ -15,5 +15,13 @@ type AuditLogDto struct {
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]"`
Location string `form:"filters[location]"`
}

View File

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

View File

@@ -40,13 +40,11 @@ func MapStruct[S any, D any](source S, destination *D) error {
}
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
// Loop through the fields of the destination struct
for i := 0; i < destVal.NumField(); i++ {
destField := destVal.Field(i)
destFieldType := destVal.Type().Field(i)
if destFieldType.Anonymous {
// Recursively handle embedded structs
if err := mapStructInternal(sourceVal, destField); err != nil {
return err
}
@@ -55,63 +53,110 @@ func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
sourceField := sourceVal.FieldByName(destFieldType.Name)
// If the source field is valid and can be assigned to the destination field
if sourceField.IsValid() && destField.CanSet() {
// Handle direct assignment for simple types
if sourceField.Type() == destField.Type() {
destField.Set(sourceField)
} else if sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice {
// Handle slices
if sourceField.Type().Elem() == destField.Type().Elem() {
// Direct assignment for slices of primitive types or non-struct elements
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
newSlice.Index(j).Set(sourceField.Index(j))
}
destField.Set(newSlice)
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
// Recursively map slices of structs
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
for j := 0; j < sourceField.Len(); j++ {
// Get the element from both source and destination slice
sourceElem := sourceField.Index(j)
destElem := reflect.New(destField.Type().Elem()).Elem()
// Recursively map the struct elements
if err := mapStructInternal(sourceElem, destElem); err != nil {
return err
}
// Set the mapped element in the new slice
newSlice.Index(j).Set(destElem)
}
destField.Set(newSlice)
}
} else if sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct {
// Recursively map nested structs
if err := mapStructInternal(sourceField, destField); err != nil {
return err
}
} else {
// Type switch for specific type conversions
switch sourceField.Interface().(type) {
case datatype.DateTime:
// Convert datatype.DateTime to time.Time
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
dateValue := sourceField.Interface().(datatype.DateTime)
destField.Set(reflect.ValueOf(dateValue.ToTime()))
}
}
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
}

View File

@@ -1,31 +1,48 @@
package dto
type PublicOidcClientDto struct {
type OidcClientMetaDataDto struct {
ID string `json:"id"`
Name string `json:"name"`
HasLogo bool `json:"hasLogo"`
}
type OidcClientDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
OidcClientMetaDataDto
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
}
type OidcClientWithAllowedUserGroupsDto struct {
PublicOidcClientDto
CallbackURLs []string `json:"callbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
OidcClientDto
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
OidcClientDto
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
}
type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"`
CallbackURLs []string `json:"callbackURLs" binding:"required"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
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"`
}
type OidcClientCredentialsDto struct {
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
}
type OidcClientFederatedIdentityDto struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"`
}
type AuthorizeOidcClientRequestDto struct {
@@ -48,13 +65,91 @@ type AuthorizationRequiredDto struct {
}
type OidcCreateTokensDto struct {
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
GrantType string `form:"grant_type" binding:"required"`
Code string `form:"code"`
DeviceCode string `form:"device_code"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
CodeVerifier string `form:"code_verifier"`
RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"`
}
type OidcUpdateAllowedUserGroupsDto struct {
UserGroupIDs []string `json:"userGroupIds" binding:"required"`
}
type OidcLogoutDto struct {
IdTokenHint string `form:"id_token_hint"`
ClientId string `form:"client_id"`
PostLogoutRedirectUri string `form:"post_logout_redirect_uri"`
State string `form:"state"`
}
type OidcTokenResponseDto struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
IdToken string `json:"id_token,omitempty"`
RefreshToken string `json:"refresh_token,omitempty"`
ExpiresIn int `json:"expires_in"`
}
type OidcIntrospectionResponseDto struct {
Active bool `json:"active"`
TokenType string `json:"token_type,omitempty"`
Scope string `json:"scope,omitempty"`
Expiration int64 `json:"exp,omitempty"`
IssuedAt int64 `json:"iat,omitempty"`
NotBefore int64 `json:"nbf,omitempty"`
Subject string `json:"sub,omitempty"`
Audience []string `json:"aud,omitempty"`
Issuer string `json:"iss,omitempty"`
Identifier string `json:"jti,omitempty"`
}
type OidcDeviceAuthorizationRequestDto struct {
ClientID string `form:"client_id" binding:"required"`
Scope string `form:"scope" binding:"required"`
ClientSecret string `form:"client_secret"`
ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"`
}
type OidcDeviceAuthorizationResponseDto struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
RequiresAuthorization bool `json:"requires_authorization"`
}
type OidcDeviceTokenRequestDto struct {
GrantType string `form:"grant_type" binding:"required,eq=urn:ietf:params:oauth:grant-type:device_code"`
DeviceCode string `form:"device_code" binding:"required"`
ClientID string `form:"client_id"`
ClientSecret string `form:"client_secret"`
}
type DeviceCodeInfoDto struct {
Scope string `json:"scope"`
AuthorizationRequired bool `json:"authorizationRequired"`
Client OidcClientMetaDataDto `json:"client"`
}
type AuthorizedOidcClientDto struct {
Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"`
}
type OidcClientPreviewDto struct {
IdToken map[string]interface{} `json:"idToken"`
AccessToken map[string]interface{} `json:"accessToken"`
UserInfo map[string]interface{} `json:"userInfo"`
}

View File

@@ -0,0 +1,10 @@
package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils"
type Pagination = utils.PaginationResponse
type Paginated[T any] struct {
Data []T `json:"data"`
Pagination Pagination `json:"pagination"`
}

View File

@@ -9,25 +9,38 @@ type UserDto struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
}
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:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"`
LdapID string `json:"-"`
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:"-"`
}
type OneTimeAccessTokenCreateDto struct {
UserID string `json:"userId" binding:"required"`
UserID string `json:"userId"`
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type OneTimeAccessEmailDto struct {
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
Email string `json:"email" binding:"required,email"`
RedirectPath string `json:"redirectPath"`
}
type OneTimeAccessEmailAsAdminDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
}
type UserUpdateUserGroupDto struct {
UserGroupIds []string `json:"userGroupIds" binding:"required"`
}

View File

@@ -4,6 +4,15 @@ import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`

View File

@@ -1,10 +1,11 @@
package dto
import (
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
"log"
"regexp"
"github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10"
)
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
@@ -16,22 +17,10 @@ var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return matched
}
var validateClaimKey validator.Func = func(fl validator.FieldLevel) bool {
// The string can only contain letters and numbers
regex := "^[A-Za-z0-9]*$"
matched, _ := regexp.MatchString(regex, fl.Field().String())
return matched
}
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)
}
}
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
if err := v.RegisterValidation("claimKey", validateClaimKey); err != nil {
log.Fatalf("Failed to register custom validation: %v", err)
}
}
}

View File

@@ -0,0 +1,86 @@
package job
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
// Send every 24 hours
jobs := &AnalyticsJob{
appConfig: appConfig,
httpClient: httpClient,
}
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
}
type AnalyticsJob struct {
appConfig *service.AppConfigService
httpClient *http.Client
}
// sendHeartbeat sends a heartbeat to the analytics service
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
// Skip if analytics are disabled or not in production environment
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
body, err := json.Marshal(struct {
Version string `json:"version"`
InstanceID string `json:"instance_id"`
}{
Version: common.Version,
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
})
if err != nil {
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
}
_, err = backoff.Retry(
parentCtx,
func() (struct{}, error) {
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
if err != nil {
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := j.httpClient.Do(req)
if err != nil {
return struct{}{}, fmt.Errorf("failed to send request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return struct{}{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
}
return struct{}{}, nil
},
backoff.WithBackOff(backoff.NewExponentialBackOff()),
backoff.WithMaxTries(3),
)
if err != nil {
return fmt.Errorf("heartbeat request failed: %w", err)
}
return nil
}

View File

@@ -0,0 +1,49 @@
package job
import (
"context"
"fmt"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type ApiKeyEmailJobs struct {
apiKeyService *service.ApiKeyService
appConfigService *service.AppConfigService
}
func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *service.ApiKeyService, appConfigService *service.AppConfigService) error {
jobs := &ApiKeyEmailJobs{
apiKeyService: apiKeyService,
appConfigService: appConfigService,
}
// Send every day at midnight
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
}
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
// Skip if the feature is disabled
if !j.appConfigService.GetDbConfig().EmailApiKeyExpirationEnabled.IsTrue() {
return nil
}
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
if err != nil {
return fmt.Errorf("failed to list expiring API keys: %w", err)
}
for _, key := range apiKeys {
if key.User.Email == "" {
continue
}
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
}
}
return nil
}

View File

@@ -1,69 +0,0 @@
package job
import (
"log"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
func RegisterDbCleanupJobs(db *gorm.DB) {
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
jobs := &Jobs{db: db}
registerJob(scheduler, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions)
registerJob(scheduler, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens)
registerJob(scheduler, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes)
scheduler.Start()
}
type Jobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *Jobs) clearWebauthnSessions() error {
return j.db.Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *Jobs) clearOneTimeAccessTokens() error {
return j.db.Debug().Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *Jobs) clearOidcAuthorizationCodes() error {
return j.db.Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).Error
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *Jobs) clearAuditLogs() error {
return j.db.Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).Error
}
func registerJob(scheduler gocron.Scheduler, name string, interval string, job func() error) {
_, err := scheduler.NewJob(
gocron.CronJob(interval, false),
gocron.NewTask(job),
gocron.WithEventListeners(
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
log.Printf("Job %q run successfully", name)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
log.Printf("Job %q failed with error: %v", name, err)
}),
),
)
if err != nil {
log.Fatalf("Failed to register job %q: %v", name, err)
}
}

View File

@@ -0,0 +1,103 @@
package job
import (
"context"
"errors"
"fmt"
"log/slog"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db}
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
return errors.Join(
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
)
}
type DbCleanupJobs struct {
db *gorm.DB
}
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
if st.Error != nil {
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
}
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
return nil
}

View File

@@ -0,0 +1,79 @@
package job
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &FileCleanupJobs{db: db}
// Run every 24 hours
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
}
type FileCleanupJobs struct {
db *gorm.DB
}
// ClearUnusedDefaultProfilePictures deletes default profile pictures that don't match any user's initials
func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) error {
var users []model.User
err := j.db.
WithContext(ctx).
Find(&users).
Error
if err != nil {
return fmt.Errorf("failed to fetch users: %w", err)
}
// Create a map to track which initials are in use
initialsInUse := make(map[string]struct{})
for _, user := range users {
initialsInUse[user.Initials()] = struct{}{}
}
defaultPicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults"
if _, err := os.Stat(defaultPicturesDir); os.IsNotExist(err) {
return nil
}
files, err := os.ReadDir(defaultPicturesDir)
if err != nil {
return fmt.Errorf("failed to read default profile pictures directory: %w", err)
}
filesDeleted := 0
for _, file := range files {
if file.IsDir() {
continue // Skip directories
}
filename := file.Name()
initials := strings.TrimSuffix(filename, ".png")
// If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok {
filePath := filepath.Join(defaultPicturesDir, filename)
if err := os.Remove(filePath); err != nil {
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else {
filesDeleted++
}
}
}
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
return nil
}

View File

@@ -0,0 +1,31 @@
package job
import (
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type GeoLiteUpdateJobs struct {
geoLiteService *service.GeoLiteService
}
func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteService *service.GeoLiteService) error {
// Check if the service needs periodic updating
if geoLiteService.DisableUpdater() {
// Nothing to do
return nil
}
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Run every 24 hours (and right away)
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
}
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
return j.geoLiteService.UpdateDatabase(ctx)
}

View File

@@ -1,9 +1,11 @@
package job
import (
"log"
"context"
"time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
@@ -12,28 +14,17 @@ type LdapJobs struct {
appConfigService *service.AppConfigService
}
func RegisterLdapJobs(ldapService *service.LdapService, appConfigService *service.AppConfigService) {
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
scheduler, err := gocron.NewScheduler()
if err != nil {
log.Fatalf("Failed to create a new scheduler: %s", err)
}
// Register the job to run every hour
registerJob(scheduler, "SyncLdap", "0 * * * *", jobs.syncLdap)
// Run the job immediately on startup
if err := jobs.syncLdap(); err != nil {
log.Printf("Failed to sync LDAP: %s", err)
}
scheduler.Start()
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
}
func (j *LdapJobs) syncLdap() error {
if j.appConfigService.DbConfig.LdapEnabled.Value == "true" {
return j.ldapService.SyncAll()
func (j *LdapJobs) syncLdap(ctx context.Context) error {
if !j.appConfigService.GetDbConfig().LdapEnabled.IsTrue() {
return nil
}
return nil
return j.ldapService.SyncAll(ctx)
}

View File

@@ -0,0 +1,83 @@
package job
import (
"context"
"fmt"
"log/slog"
"github.com/go-co-op/gocron/v2"
"github.com/google/uuid"
)
type Scheduler struct {
scheduler gocron.Scheduler
}
func NewScheduler() (*Scheduler, error) {
scheduler, err := gocron.NewScheduler()
if err != nil {
return nil, fmt.Errorf("failed to create a new scheduler: %w", err)
}
return &Scheduler{
scheduler: scheduler,
}, nil
}
// Run the scheduler.
// This function blocks until the context is canceled.
func (s *Scheduler) Run(ctx context.Context) error {
slog.Info("Starting job scheduler")
s.scheduler.Start()
// Block until context is canceled
<-ctx.Done()
err := s.scheduler.Shutdown()
if err != nil {
slog.Error("Error shutting down job scheduler", slog.Any("error", err))
} else {
slog.Info("Job scheduler shut down")
}
return nil
}
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
jobOptions := []gocron.JobOption{
gocron.WithContext(ctx),
gocron.WithEventListeners(
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Starting job",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
slog.Info("Job run successfully",
slog.String("name", name),
slog.String("id", jobID.String()),
)
}),
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
slog.Error("Job failed with error",
slog.String("name", name),
slog.String("id", jobID.String()),
slog.Any("error", err),
)
}),
),
}
if runImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
}
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err)
}
return nil
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
type ApiKeyAuthMiddleware struct {
apiKeyService *service.ApiKeyService
jwtService *service.JwtService
}
func NewApiKeyAuthMiddleware(apiKeyService *service.ApiKeyService, jwtService *service.JwtService) *ApiKeyAuthMiddleware {
return &ApiKeyAuthMiddleware{
apiKeyService: apiKeyService,
jwtService: jwtService,
}
}
func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil {
c.Abort()
_ = c.Error(err)
return
}
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Next()
}
}
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
apiKey := c.GetHeader("X-API-KEY")
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil {
return "", false, &common.NotSignedInError{}
}
if user.Disabled {
return "", false, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{}
}
return user.ID, user.IsAdmin, nil
}

View File

@@ -0,0 +1,103 @@
package middleware
import (
"errors"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/service"
)
// AuthMiddleware is a wrapper middleware that delegates to either API key or JWT authentication
type AuthMiddleware struct {
apiKeyMiddleware *ApiKeyAuthMiddleware
jwtMiddleware *JwtAuthMiddleware
options AuthOptions
}
type AuthOptions struct {
AdminRequired bool
SuccessOptional bool
}
func NewAuthMiddleware(
apiKeyService *service.ApiKeyService,
userService *service.UserService,
jwtService *service.JwtService,
) *AuthMiddleware {
return &AuthMiddleware{
apiKeyMiddleware: NewApiKeyAuthMiddleware(apiKeyService, jwtService),
jwtMiddleware: NewJwtAuthMiddleware(jwtService, userService),
options: AuthOptions{
AdminRequired: true,
SuccessOptional: false,
},
}
}
// WithAdminNotRequired allows the middleware to continue with the request even if the user is not an admin
func (m *AuthMiddleware) WithAdminNotRequired() *AuthMiddleware {
// Create a new instance to avoid modifying the original
clone := &AuthMiddleware{
apiKeyMiddleware: m.apiKeyMiddleware,
jwtMiddleware: m.jwtMiddleware,
options: m.options,
}
clone.options.AdminRequired = false
return clone
}
// WithSuccessOptional allows the middleware to continue with the request even if authentication fails
func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
// Create a new instance to avoid modifying the original
clone := &AuthMiddleware{
apiKeyMiddleware: m.apiKeyMiddleware,
jwtMiddleware: m.jwtMiddleware,
options: m.options,
}
clone.options.SuccessOptional = true
return clone
}
func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
if c.IsAborted() {
return
}
c.Next()
return
}
// If JWT auth failed and the error is not a NotSignedInError, abort the request
if !errors.Is(err, &common.NotSignedInError{}) {
c.Abort()
_ = c.Error(err)
return
}
// JWT auth failed, try API key auth
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
if err == nil {
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
if c.IsAborted() {
return
}
c.Next()
return
}
if m.options.SuccessOptional {
c.Next()
return
}
// Both JWT and API key auth failed
c.Abort()
_ = c.Error(err)
}
}

View File

@@ -1,8 +1,9 @@
package middleware
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
type CorsMiddleware struct{}
@@ -13,17 +14,23 @@ func NewCorsMiddleware() *CorsMiddleware {
func (m *CorsMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Allow all origins for the token endpoint
if c.FullPath() == "/api/oidc/token" {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
} else {
c.Writer.Header().Set("Access-Control-Allow-Origin", common.EnvConfig.AppURL)
path := c.FullPath()
if path == "" {
// The router doesn't map preflight requests, so we need to use the raw URL path
path = c.Request.URL.Path
}
c.Writer.Header().Set("Access-Control-Allow-Headers", "*")
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT")
if !isCorsPath(path) {
c.Next()
return
}
if c.Request.Method == "OPTIONS" {
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
// Preflight request
if c.Request.Method == http.MethodOptions {
c.AbortWithStatus(204)
return
}
@@ -31,3 +38,17 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
c.Next()
}
}
func isCorsPath(path string) bool {
switch path {
case "/api/oidc/token",
"/api/oidc/userinfo",
"/oidc/end-session",
"/api/oidc/introspect",
"/.well-known/jwks.json",
"/.well-known/openid-configuration":
return true
default:
return false
}
}

View File

@@ -19,7 +19,7 @@ func (m *FileSizeLimitMiddleware) Add(maxSize int64) gin.HandlerFunc {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxSize)
if err := c.Request.ParseMultipartForm(maxSize); err != nil {
err = &common.FileTooLargeError{MaxSize: formatFileSize(maxSize)}
c.Error(err)
_ = c.Error(err)
c.Abort()
return
}

View File

@@ -10,51 +10,64 @@ import (
)
type JwtAuthMiddleware struct {
jwtService *service.JwtService
ignoreUnauthenticated bool
userService *service.UserService
jwtService *service.JwtService
}
func NewJwtAuthMiddleware(jwtService *service.JwtService, ignoreUnauthenticated bool) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService, ignoreUnauthenticated: ignoreUnauthenticated}
func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.UserService) *JwtAuthMiddleware {
return &JwtAuthMiddleware{jwtService: jwtService, userService: userService}
}
func (m *JwtAuthMiddleware) Add(adminOnly bool) gin.HandlerFunc {
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
return func(c *gin.Context) {
// Extract the token from the cookie or the Authorization header
token, err := c.Cookie(cookie.AccessTokenCookieName)
userID, isAdmin, err := m.Verify(c, adminRequired)
if err != nil {
authorizationHeaderSplitted := strings.Split(c.GetHeader("Authorization"), " ")
if len(authorizationHeaderSplitted) == 2 {
token = authorizationHeaderSplitted[1]
} else if m.ignoreUnauthenticated {
c.Next()
return
} else {
c.Error(&common.NotSignedInError{})
c.Abort()
return
}
}
claims, err := m.jwtService.VerifyAccessToken(token)
if err != nil && m.ignoreUnauthenticated {
c.Next()
return
} else if err != nil {
c.Error(&common.NotSignedInError{})
c.Abort()
_ = c.Error(err)
return
}
// Check if the user is an admin
if adminOnly && !claims.IsAdmin {
c.Error(&common.MissingPermissionError{})
c.Abort()
return
}
c.Set("userID", claims.Subject)
c.Set("userIsAdmin", claims.IsAdmin)
c.Set("userID", userID)
c.Set("userIsAdmin", isAdmin)
c.Next()
}
}
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) {
// Extract the token from the cookie
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
if err != nil {
// Try to extract the token from the Authorization header if it's not in the cookie
var ok bool
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
if !ok || accessToken == "" {
return "", false, &common.NotSignedInError{}
}
}
token, err := m.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", false, &common.NotSignedInError{}
}
subject, ok := token.Subject()
if !ok {
_ = c.Error(&common.TokenInvalidError{})
return
}
user, err := m.userService.GetUser(c, subject)
if err != nil {
return "", false, &common.NotSignedInError{}
}
if user.Disabled {
return "", false, &common.UserDisabledError{}
}
if adminRequired && !user.IsAdmin {
return "", false, &common.MissingPermissionError{}
}
return subject, isAdmin, nil
}

View File

@@ -36,7 +36,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
limiter := getLimiter(ip, limit, burst, &mu, clients)
if !limiter.Allow() {
c.Error(&common.TooManyRequestsError{})
_ = c.Error(&common.TooManyRequestsError{})
c.Abort()
return
}

View File

@@ -0,0 +1,17 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type ApiKey struct {
Base
Name string `sortable:"true"`
Key string
Description *string
ExpiresAt datatype.DateTime `sortable:"true"`
LastUsedAt *datatype.DateTime `sortable:"true"`
ExpirationEmailSent bool
UserID string
User User
}

View File

@@ -1,47 +1,194 @@
package model
import (
"errors"
"fmt"
"reflect"
"slices"
"strconv"
"strings"
"time"
)
type AppConfigVariable struct {
Key string `gorm:"primaryKey;not null"`
Type string
IsPublic bool
IsInternal bool
Value string
DefaultValue string
Key string `gorm:"primaryKey;not null"`
Value string
}
// IsTrue returns true if the value is a truthy string, such as "true", "t", "yes", "1", etc.
func (a *AppConfigVariable) IsTrue() bool {
ok, _ := strconv.ParseBool(a.Value)
return ok
}
// AsDurationMinutes returns the value as a time.Duration, interpreting the string as a whole number of minutes.
func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
val, err := strconv.Atoi(a.Value)
if err != nil {
return 0
}
return time.Duration(val) * time.Minute
}
type AppConfig struct {
// General
AppName AppConfigVariable
SessionDuration AppConfigVariable
EmailsVerified AppConfigVariable
AllowOwnAccountEdit AppConfigVariable
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
// Internal
BackgroundImageType AppConfigVariable
LogoLightImageType AppConfigVariable
LogoDarkImageType AppConfigVariable
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email
SmtpHost AppConfigVariable
SmtpPort AppConfigVariable
SmtpFrom AppConfigVariable
SmtpUser AppConfigVariable
SmtpPassword AppConfigVariable
SmtpTls AppConfigVariable
SmtpSkipCertVerify AppConfigVariable
EmailLoginNotificationEnabled AppConfigVariable
EmailOneTimeAccessEnabled AppConfigVariable
SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"`
SmtpUser AppConfigVariable `key:"smtpUser"`
SmtpPassword AppConfigVariable `key:"smtpPassword"`
SmtpTls AppConfigVariable `key:"smtpTls"`
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
EmailOneTimeAccessAsUnauthenticatedEnabled AppConfigVariable `key:"emailOneTimeAccessAsUnauthenticatedEnabled,public"` // Public
EmailOneTimeAccessAsAdminEnabled AppConfigVariable `key:"emailOneTimeAccessAsAdminEnabled,public"` // Public
EmailApiKeyExpirationEnabled AppConfigVariable `key:"emailApiKeyExpirationEnabled"`
// LDAP
LdapEnabled AppConfigVariable
LdapUrl AppConfigVariable
LdapBindDn AppConfigVariable
LdapBindPassword AppConfigVariable
LdapBase AppConfigVariable
LdapSkipCertVerify AppConfigVariable
LdapAttributeUserUniqueIdentifier AppConfigVariable
LdapAttributeUserUsername AppConfigVariable
LdapAttributeUserEmail AppConfigVariable
LdapAttributeUserFirstName AppConfigVariable
LdapAttributeUserLastName AppConfigVariable
LdapAttributeGroupUniqueIdentifier AppConfigVariable
LdapAttributeGroupName AppConfigVariable
LdapAttributeAdminGroup AppConfigVariable
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
LdapUrl AppConfigVariable `key:"ldapUrl"`
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
LdapBase AppConfigVariable `key:"ldapBase"`
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
LdapSkipCertVerify AppConfigVariable `key:"ldapSkipCertVerify"`
LdapAttributeUserUniqueIdentifier AppConfigVariable `key:"ldapAttributeUserUniqueIdentifier"`
LdapAttributeUserUsername AppConfigVariable `key:"ldapAttributeUserUsername"`
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"`
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
}
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
// Use reflection to iterate through all fields
cfgValue := reflect.ValueOf(c).Elem()
cfgType := cfgValue.Type()
var res []AppConfigVariable
for i := range cfgType.NumField() {
field := cfgType.Field(i)
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
if key == "" {
continue
}
// If we're only showing public variables and this is not public, skip it
if !showAll && attrs != "public" {
continue
}
fieldValue := cfgValue.Field(i)
appConfigVariable := AppConfigVariable{
Key: key,
Value: fieldValue.FieldByName("Value").String(),
}
res = append(res, appConfigVariable)
}
return res
}
func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches
for i := range rt.NumField() {
// Grab only the first part of the key, if there's a comma with additional properties
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
keyFromTag := tagValue[0]
isInternal = slices.Contains(tagValue, "internal")
if keyFromTag != key {
continue
}
valueField := rv.Field(i).FieldByName("Value")
return valueField.String(), isInternal, nil
}
// If we are here, the config key was not found
return "", false, AppConfigKeyNotFoundError{field: key}
}
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
rv := reflect.ValueOf(c).Elem()
rt := rv.Type()
// Find the field in the struct whose "key" tag matches, then update that
for i := range rt.NumField() {
// Separate the key (before the comma) from any optional attributes after
tagValue, attrs, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
if tagValue != key {
continue
}
// If the field is internal and noInternal is true, we skip that
if noInternal && attrs == "internal" {
return AppConfigInternalForbiddenError{field: key}
}
valueField := rv.Field(i).FieldByName("Value")
if !valueField.CanSet() {
return fmt.Errorf("field Value in AppConfigVariable is not settable for config key '%s'", key)
}
// Update the value
valueField.SetString(value)
// Return once updated
return nil
}
// If we're here, we have not found the right field to update
return AppConfigKeyNotFoundError{field: key}
}
type AppConfigKeyNotFoundError struct {
field string
}
func (e AppConfigKeyNotFoundError) Error() string {
return fmt.Sprintf("cannot find config key '%s'", e.field)
}
func (e AppConfigKeyNotFoundError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigKeyNotFoundError
x := AppConfigKeyNotFoundError{}
return errors.As(target, &x)
}
type AppConfigInternalForbiddenError struct {
field string
}
func (e AppConfigInternalForbiddenError) Error() string {
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
}
func (e AppConfigInternalForbiddenError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AppConfigInternalForbiddenError
x := AppConfigInternalForbiddenError{}
return errors.As(target, &x)
}

View File

@@ -0,0 +1,129 @@
// We use model_test here to avoid an import cycle
package model_test
import (
"reflect"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
)
func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
tests := []struct {
name string
value string
expected time.Duration
expectedSeconds int
}{
{
name: "valid positive integer",
value: "60",
expected: 60 * time.Minute,
expectedSeconds: 3600,
},
{
name: "valid zero integer",
value: "0",
expected: 0,
expectedSeconds: 0,
},
{
name: "negative integer",
value: "-30",
expected: -30 * time.Minute,
expectedSeconds: -1800,
},
{
name: "invalid non-integer",
value: "not-a-number",
expected: 0,
expectedSeconds: 0,
},
{
name: "empty string",
value: "",
expected: 0,
expectedSeconds: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
configVar := model.AppConfigVariable{
Value: tt.value,
}
result := configVar.AsDurationMinutes()
assert.Equal(t, tt.expected, result)
assert.Equal(t, tt.expectedSeconds, int(result.Seconds()))
})
}
}
// This test ensures that the model.AppConfig and dto.AppConfigUpdateDto structs match:
// - They should have the same properties, where the "json" tag of dto.AppConfigUpdateDto should match the "key" tag in model.AppConfig
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig
// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time
func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
appConfigType := reflect.TypeOf(model.AppConfig{})
updateDtoType := reflect.TypeOf(dto.AppConfigUpdateDto{})
// Process AppConfig fields
appConfigFields := make(map[string]string)
for i := 0; i < appConfigType.NumField(); i++ {
field := appConfigType.Field(i)
if field.Tag.Get("key") == "" {
// Skip internal fields
continue
}
// Extract the key name from the tag (takes the part before any comma)
keyTag := field.Tag.Get("key")
keyName, _, _ := strings.Cut(keyTag, ",")
appConfigFields[field.Name] = keyName
}
// Process AppConfigUpdateDto fields
dtoFields := make(map[string]string)
for i := 0; i < updateDtoType.NumField(); i++ {
field := updateDtoType.Field(i)
// Extract the json name from the tag (takes the part before any binding constraints)
jsonTag := field.Tag.Get("json")
jsonName, _, _ := strings.Cut(jsonTag, ",")
dtoFields[jsonName] = field.Name
}
// Verify every AppConfig field has a matching DTO field with the same name
for fieldName, keyName := range appConfigFields {
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
// Skip internal fields that shouldn't be in the DTO
continue
}
// Check if there's a DTO field with a matching JSON tag
_, exists := dtoFields[keyName]
assert.True(t, exists, "Field %s with key '%s' in AppConfig has no matching field in AppConfigUpdateDto", fieldName, keyName)
}
// Verify every DTO field has a matching AppConfig field
for jsonName, fieldName := range dtoFields {
// Find a matching field in AppConfig by key tag
found := false
for _, keyName := range appConfigFields {
if keyName == jsonName {
found = true
break
}
}
assert.True(t, found, "Field %s with json tag '%s' in AppConfigUpdateDto has no matching field in AppConfig", fieldName, jsonName)
}
}

View File

@@ -3,7 +3,7 @@ package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)
type AuditLog struct {
@@ -14,24 +14,29 @@ type AuditLog struct {
Country string `sortable:"true"`
City string `sortable:"true"`
UserAgent string `sortable:"true"`
UserID string
Username string `gorm:"-"`
Data AuditLogData
UserID string
User User
}
type AuditLogData map[string]string
type AuditLogData map[string]string //nolint:recvcheck
type AuditLogEvent string
type AuditLogEvent string //nolint:recvcheck
const (
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION"
)
// Scan and Value methods for GORM to handle the custom type
func (e *AuditLogEvent) Scan(value interface{}) error {
func (e *AuditLogEvent) Scan(value any) error {
*e = AuditLogEvent(value.(string))
return nil
}
@@ -40,11 +45,14 @@ func (e AuditLogEvent) Value() (driver.Value, error) {
return string(e), nil
}
func (d *AuditLogData) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
func (d *AuditLogData) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, d)
} else {
return errors.New("type assertion to []byte failed")
case string:
return json.Unmarshal([]byte(v), d)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}

View File

@@ -4,20 +4,20 @@ import (
"time"
"github.com/google/uuid"
model "github.com/pocket-id/pocket-id/backend/internal/model/types"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
// Base contains common columns for all tables.
type Base struct {
ID string `gorm:"primaryKey;not null"`
CreatedAt model.DateTime `sortable:"true"`
ID string `gorm:"primaryKey;not null"`
CreatedAt datatype.DateTime `sortable:"true"`
}
func (b *Base) BeforeCreate(_ *gorm.DB) (err error) {
if b.ID == "" {
b.ID = uuid.New().String()
}
b.CreatedAt = model.DateTime(time.Now())
b.CreatedAt = datatype.DateTime(time.Now())
return
}

View File

@@ -3,10 +3,11 @@ package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
)
type UserAuthorizedOidcClient struct {
@@ -37,35 +38,108 @@ type OidcAuthorizationCode struct {
type OidcClient struct {
Base
Name string `sortable:"true"`
Secret string
CallbackURLs CallbackURLs
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
Name string `sortable:"true"`
Secret string
CallbackURLs UrlList
LogoutCallbackURLs UrlList
ImageType *string
HasLogo bool `gorm:"-"`
IsPublic bool
PkceEnabled bool
Credentials OidcClientCredentials
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string
CreatedBy User
}
type OidcRefreshToken struct {
Base
Token string
ExpiresAt datatype.DateTime
Scope string
UserID string
User User
ClientID string
Client OidcClient
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
return nil
}
type CallbackURLs []string
type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
}
func (cu *CallbackURLs) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
return json.Unmarshal(v, cu)
} else {
return errors.New("type assertion to []byte failed")
type OidcClientFederatedIdentity struct {
Issuer string `json:"issuer"`
Subject string `json:"subject,omitempty"`
Audience string `json:"audience,omitempty"`
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
}
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
if issuer == "" {
return OidcClientFederatedIdentity{}, false
}
for _, fi := range occ.FederatedIdentities {
if fi.Issuer == issuer {
return fi, true
}
}
return OidcClientFederatedIdentity{}, false
}
func (occ *OidcClientCredentials) Scan(value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, occ)
case string:
return json.Unmarshal([]byte(v), occ)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}
func (cu CallbackURLs) Value() (driver.Value, error) {
func (occ OidcClientCredentials) Value() (driver.Value, error) {
return json.Marshal(occ)
}
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)
}
}
func (cu UrlList) Value() (driver.Value, error) {
return json.Marshal(cu)
}
type OidcDeviceCode struct {
Base
DeviceCode string
UserCode string
Scope string
ExpiresAt datatype.DateTime
IsAuthorized bool
UserID *string
User User
ClientID string
Client OidcClient
}

View File

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

View File

@@ -1,9 +1,13 @@
package model
import (
"strings"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type User struct {
@@ -14,7 +18,9 @@ type User struct {
FirstName string `sortable:"true"`
LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string
Disabled bool `sortable:"true"`
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -62,6 +68,15 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
func (u User) Initials() string {
first := utils.GetFirstCharacter(u.FirstName)
last := utils.GetFirstCharacter(u.LastName)
if first == "" && last == "" && len(u.Username) >= 2 {
return strings.ToUpper(u.Username[:2])
}
return strings.ToUpper(first + last)
}
type OneTimeAccessToken struct {
Base
Token string

View File

@@ -3,7 +3,7 @@ package model
import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/go-webauthn/webauthn/protocol"
@@ -45,15 +45,17 @@ type PublicKeyCredentialRequestOptions struct {
Timeout time.Duration
}
type AuthenticatorTransportList []protocol.AuthenticatorTransport
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type
func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
if v, ok := value.([]byte); ok {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, atl)
} else {
return errors.New("type assertion to []byte failed")
case string:
return json.Unmarshal([]byte(v), atl)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}

View File

@@ -0,0 +1,165 @@
package service
import (
"context"
"errors"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type ApiKeyService struct {
db *gorm.DB
emailService *EmailService
}
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) {
query := s.db.
WithContext(ctx).
Where("user_id = ?", userID).
Model(&model.ApiKey{})
var apiKeys []model.ApiKey
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &apiKeys)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
return apiKeys, pagination, nil
}
func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input dto.ApiKeyCreateDto) (model.ApiKey, string, error) {
// Check if expiration is in the future
if !input.ExpiresAt.ToTime().After(time.Now()) {
return model.ApiKey{}, "", &common.APIKeyExpirationDateError{}
}
// Generate a secure random API key
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return model.ApiKey{}, "", err
}
apiKey := model.ApiKey{
Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: &input.Description,
ExpiresAt: datatype.DateTime(input.ExpiresAt),
UserID: userID,
}
err = s.db.
WithContext(ctx).
Create(&apiKey).
Error
if err != nil {
return model.ApiKey{}, "", err
}
// Return the raw token only once - it cannot be retrieved later
return apiKey, token, nil
}
func (s *ApiKeyService) RevokeApiKey(ctx context.Context, userID, apiKeyID string) error {
var apiKey model.ApiKey
err := s.db.
WithContext(ctx).
Where("id = ? AND user_id = ?", apiKeyID, userID).
Delete(&apiKey).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &common.APIKeyNotFoundError{}
}
return err
}
return nil
}
func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (model.User, error) {
if apiKey == "" {
return model.User{}, &common.NoAPIKeyProvidedError{}
}
now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey)
var key model.ApiKey
err := s.db.
WithContext(ctx).
Model(&model.ApiKey{}).
Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&model.ApiKey{
LastUsedAt: utils.Ptr(datatype.DateTime(now)),
}).
Preload("User").
First(&key).
Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, &common.InvalidAPIKeyError{}
}
return model.User{}, err
}
return key.User, nil
}
func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int) ([]model.ApiKey, error) {
var keys []model.ApiKey
now := time.Now()
cutoff := now.AddDate(0, 0, daysAhead)
err := s.db.
WithContext(ctx).
Preload("User").
Where("expires_at > ? AND expires_at <= ? AND expiration_email_sent = ?", datatype.DateTime(now), datatype.DateTime(cutoff), false).
Find(&keys).
Error
return keys, err
}
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
user := apiKey.User
if user.ID == "" {
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
return err
}
}
err := SendEmail(ctx, s.emailService, email.Address{
Name: user.FullName(),
Email: user.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(),
Name: user.FirstName,
})
if err != nil {
return err
}
// Mark the API key as having had an expiration email sent
return s.db.WithContext(ctx).
Model(&model.ApiKey{}).
Where("id = ?", apiKey.ID).
Update("expiration_email_sent", true).
Error
}

View File

@@ -1,355 +1,463 @@
package service
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"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"gorm.io/gorm"
)
type AppConfigService struct {
DbConfig *model.AppConfig
dbConfig atomic.Pointer[model.AppConfig]
db *gorm.DB
}
func NewAppConfigService(db *gorm.DB) *AppConfigService {
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
service := &AppConfigService{
DbConfig: &defaultDbConfig,
db: db,
db: db,
}
if err := service.InitDbConfig(); err != nil {
err := service.LoadDbConfig(ctx)
if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err)
}
err = service.initInstanceID(ctx)
if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err)
}
return service
}
var defaultDbConfig = model.AppConfig{
// General
AppName: model.AppConfigVariable{
Key: "appName",
Type: "string",
IsPublic: true,
DefaultValue: "Pocket ID",
},
SessionDuration: model.AppConfigVariable{
Key: "sessionDuration",
Type: "number",
DefaultValue: "60",
},
EmailsVerified: model.AppConfigVariable{
Key: "emailsVerified",
Type: "bool",
DefaultValue: "false",
},
AllowOwnAccountEdit: model.AppConfigVariable{
Key: "allowOwnAccountEdit",
Type: "bool",
IsPublic: true,
DefaultValue: "true",
},
// Internal
BackgroundImageType: model.AppConfigVariable{
Key: "backgroundImageType",
Type: "string",
IsInternal: true,
DefaultValue: "jpg",
},
LogoLightImageType: model.AppConfigVariable{
Key: "logoLightImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
LogoDarkImageType: model.AppConfigVariable{
Key: "logoDarkImageType",
Type: "string",
IsInternal: true,
DefaultValue: "svg",
},
// Email
SmtpHost: model.AppConfigVariable{
Key: "smtpHost",
Type: "string",
},
SmtpPort: model.AppConfigVariable{
Key: "smtpPort",
Type: "number",
},
SmtpFrom: model.AppConfigVariable{
Key: "smtpFrom",
Type: "string",
},
SmtpUser: model.AppConfigVariable{
Key: "smtpUser",
Type: "string",
},
SmtpPassword: model.AppConfigVariable{
Key: "smtpPassword",
Type: "string",
},
SmtpTls: model.AppConfigVariable{
Key: "smtpTls",
Type: "bool",
DefaultValue: "true",
},
SmtpSkipCertVerify: model.AppConfigVariable{
Key: "smtpSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
EmailLoginNotificationEnabled: model.AppConfigVariable{
Key: "emailLoginNotificationEnabled",
Type: "bool",
DefaultValue: "false",
},
EmailOneTimeAccessEnabled: model.AppConfigVariable{
Key: "emailOneTimeAccessEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
// LDAP
LdapEnabled: model.AppConfigVariable{
Key: "ldapEnabled",
Type: "bool",
IsPublic: true,
DefaultValue: "false",
},
LdapUrl: model.AppConfigVariable{
Key: "ldapUrl",
Type: "string",
},
LdapBindDn: model.AppConfigVariable{
Key: "ldapBindDn",
Type: "string",
},
LdapBindPassword: model.AppConfigVariable{
Key: "ldapBindPassword",
Type: "string",
},
LdapBase: model.AppConfigVariable{
Key: "ldapBase",
Type: "string",
},
LdapSkipCertVerify: model.AppConfigVariable{
Key: "ldapSkipCertVerify",
Type: "bool",
DefaultValue: "false",
},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeUserUniqueIdentifier",
Type: "string",
},
LdapAttributeUserUsername: model.AppConfigVariable{
Key: "ldapAttributeUserUsername",
Type: "string",
},
LdapAttributeUserEmail: model.AppConfigVariable{
Key: "ldapAttributeUserEmail",
Type: "string",
},
LdapAttributeUserFirstName: model.AppConfigVariable{
Key: "ldapAttributeUserFirstName",
Type: "string",
},
LdapAttributeUserLastName: model.AppConfigVariable{
Key: "ldapAttributeUserLastName",
Type: "string",
},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{
Key: "ldapAttributeGroupUniqueIdentifier",
Type: "string",
},
LdapAttributeGroupName: model.AppConfigVariable{
Key: "ldapAttributeGroupName",
Type: "string",
},
LdapAttributeAdminGroup: model.AppConfigVariable{
Key: "ldapAttributeAdminGroup",
Type: "string",
},
}
func (s *AppConfigService) UpdateAppConfig(input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
var savedConfigVariables []model.AppConfigVariable
tx := s.db.Begin()
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
for i := 0; i < rt.NumField(); i++ {
field := rt.Field(i)
key := field.Tag.Get("json")
value := rv.FieldByName(field.Name).String()
// If the emailEnabled is set to false, disable the emailOneTimeAccessEnabled
if key == s.DbConfig.EmailOneTimeAccessEnabled.Key {
if rv.FieldByName("EmailEnabled").String() == "false" {
value = "false"
}
}
var appConfigVariable model.AppConfigVariable
if err := tx.First(&appConfigVariable, "key = ? AND is_internal = false", key).Error; err != nil {
tx.Rollback()
return nil, err
}
appConfigVariable.Value = value
if err := tx.Save(&appConfigVariable).Error; err != nil {
tx.Rollback()
return nil, err
}
savedConfigVariables = append(savedConfigVariables, appConfigVariable)
// GetDbConfig returns the application configuration.
// Important: Treat the object as read-only: do not modify its properties directly!
func (s *AppConfigService) GetDbConfig() *model.AppConfig {
v := s.dbConfig.Load()
if v == nil {
// This indicates a development-time error
panic("called GetDbConfig before DbConfig is loaded")
}
tx.Commit()
return v
}
if err := s.LoadDbConfigFromDb(); err != nil {
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"},
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: ""},
// Email
SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{},
SmtpFrom: model.AppConfigVariable{},
SmtpUser: model.AppConfigVariable{},
SmtpPassword: model.AppConfigVariable{},
SmtpTls: model.AppConfigVariable{Value: "none"},
SmtpSkipCertVerify: model.AppConfigVariable{Value: "false"},
EmailLoginNotificationEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsUnauthenticatedEnabled: model.AppConfigVariable{Value: "false"},
EmailOneTimeAccessAsAdminEnabled: model.AppConfigVariable{Value: "false"},
EmailApiKeyExpirationEnabled: model.AppConfigVariable{Value: "false"},
// LDAP
LdapEnabled: model.AppConfigVariable{Value: "false"},
LdapUrl: model.AppConfigVariable{},
LdapBindDn: model.AppConfigVariable{},
LdapBindPassword: model.AppConfigVariable{},
LdapBase: model.AppConfigVariable{},
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
LdapSkipCertVerify: model.AppConfigVariable{Value: "false"},
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeUserUsername: model.AppConfigVariable{},
LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeGroupName: model.AppConfigVariable{},
LdapAttributeAdminGroup: model.AppConfigVariable{},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
}
}
func (s *AppConfigService) updateAppConfigStartTransaction(ctx context.Context) (tx *gorm.DB, err error) {
// We start a transaction before doing any work, to ensure that we are the only ones updating the data in the database
// This works across multiple processes too
tx = s.db.Begin()
err = tx.Error
if err != nil {
return nil, fmt.Errorf("failed to begin database transaction: %w", err)
}
// With SQLite there's nothing else we need to do, because a transaction blocks the entire database
// However, with Postgres we need to manually lock the table to prevent others from doing the same
switch s.db.Name() {
case "postgres":
// We do not use "NOWAIT" so this blocks until the database is available, or the context is canceled
// Here we use a context with a 10s timeout in case the database is blocked for longer
lockCtx, lockCancel := context.WithTimeout(ctx, 10*time.Second)
defer lockCancel()
err = tx.
WithContext(lockCtx).
Exec("LOCK TABLE app_config_variables IN ACCESS EXCLUSIVE MODE").
Error
if err != nil {
tx.Rollback()
return nil, fmt.Errorf("failed to acquire lock on app_config_variables table: %w", err)
}
default:
// Nothing to do here
}
return tx, nil
}
func (s *AppConfigService) updateAppConfigUpdateDatabase(ctx context.Context, tx *gorm.DB, dbUpdate *[]model.AppConfigVariable) error {
err := tx.
WithContext(ctx).
Clauses(clause.OnConflict{
// Perform an "upsert" if the key already exists, replacing the value
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&dbUpdate).
Error
if err != nil {
return fmt.Errorf("failed to update config in database: %w", err)
}
return nil
}
func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppConfigUpdateDto) ([]model.AppConfigVariable, error) {
if common.EnvConfig.UiConfigDisabled {
return nil, &common.UiConfigDisabledError{}
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return nil, err
}
defer func() {
tx.Rollback()
}()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return nil, fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for i := range rt.NumField() {
field := rt.Field(i)
value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma
key, _, _ := strings.Cut(field.Tag.Get("json"), ",")
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
// Skip values that are internal only and can't be updated
if value == "" {
// Ignore errors here as we know the key exists
defaultValue, _, _ := defaultCfg.FieldByKey(key)
err = cfg.UpdateField(key, defaultValue, true)
} else {
err = cfg.UpdateField(key, value, true)
}
// If we tried to update an internal field, ignore the error (and do not update in the DB)
if errors.Is(err, model.AppConfigInternalForbiddenError{}) {
continue
} else if err != nil {
return nil, fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return nil, err
}
return savedConfigVariables, nil
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
s.dbConfig.Store(cfg)
// Return the updated config
res := cfg.ToAppConfigVariableSlice(true)
return res, nil
}
func (s *AppConfigService) UpdateImageType(imageName string, fileType string) error {
key := fmt.Sprintf("%sImageType", imageName)
err := s.db.Model(&model.AppConfigVariable{}).Where("key = ?", key).Update("value", fileType).Error
// UpdateAppConfigValues updates the application configuration values in the database.
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
// Count of keysAndValues must be even
if len(keysAndValues)%2 != 0 {
return errors.New("invalid number of arguments received")
}
// Start the transaction
tx, err := s.updateAppConfigStartTransaction(ctx)
if err != nil {
return err
}
defer func() {
tx.Rollback()
}()
// From here onwards, we know we are the only process/goroutine with exclusive access to the config
// Re-load the config from the database to be sure we have the correct data
cfg, err := s.loadDbConfigInternal(ctx, tx)
if err != nil {
return fmt.Errorf("failed to reload config from database: %w", err)
}
defaultCfg := s.getDefaultDbConfig()
// Iterate through all the fields to update
// We update the in-memory data (in the cfg struct) and collect values to update in the database
// (Note the += 2, as we are iterating through key-value pairs)
dbUpdate := make([]model.AppConfigVariable, 0, len(keysAndValues)/2)
for i := 0; i < len(keysAndValues); i += 2 {
key := keysAndValues[i]
value := keysAndValues[i+1]
// Ensure that the field is valid
// We do this by grabbing the default value
var defaultValue string
defaultValue, isInternal, err := defaultCfg.FieldByKey(key)
if err != nil {
return fmt.Errorf("invalid configuration key '%s': %w", key, err)
}
if !isInternal && common.EnvConfig.UiConfigDisabled {
return &common.UiConfigDisabledError{}
}
// Update the in-memory config value
// If the new value is an empty string, then we set the in-memory value to the default one
// Skip values that are internal only and can't be updated
if value == "" {
err = cfg.UpdateField(key, defaultValue, false)
} else {
err = cfg.UpdateField(key, value, false)
}
if err != nil {
return fmt.Errorf("failed to update in-memory config for key '%s': %w", key, err)
}
// We always save "value" which can be an empty string
dbUpdate = append(dbUpdate, model.AppConfigVariable{
Key: key,
Value: value,
})
}
// Update the values in the database
err = s.updateAppConfigUpdateDatabase(ctx, tx, &dbUpdate)
if err != nil {
return err
}
return s.LoadDbConfigFromDb()
}
func (s *AppConfigService) ListAppConfig(showAll bool) ([]model.AppConfigVariable, error) {
var configuration []model.AppConfigVariable
var err error
if showAll {
err = s.db.Find(&configuration).Error
} else {
err = s.db.Find(&configuration, "is_public = true").Error
}
// Commit the changes to the DB, then finally save the updated config in the object
err = tx.Commit().Error
if err != nil {
return nil, err
return fmt.Errorf("failed to commit transaction: %w", err)
}
// Set the value to the default value if it is empty
for i := range configuration {
if configuration[i].Value == "" && configuration[i].DefaultValue != "" {
configuration[i].Value = configuration[i].DefaultValue
}
}
s.dbConfig.Store(cfg)
return configuration, nil
return nil
}
func (s *AppConfigService) UpdateImage(uploadedFile *multipart.FileHeader, imageName string, oldImageType string) error {
fileType := utils.GetFileExtension(uploadedFile.Filename)
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 := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename))
mimeType := utils.GetImageMimeType(fileType)
if mimeType == "" {
return &common.FileTypeNotSupportedError{}
}
// Delete the old image if it has a different file type
// 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 := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, oldImageType)
if err := os.Remove(oldImagePath); err != nil {
oldImagePath := common.EnvConfig.UploadPath + "/application-images/" + imageName + "." + oldImageType
err = os.Remove(oldImagePath)
if err != nil {
return err
}
}
imagePath := fmt.Sprintf("%s/application-images/%s.%s", common.EnvConfig.UploadPath, imageName, fileType)
if err := utils.SaveFile(uploadedFile, imagePath); err != nil {
return err
}
// Update the file type in the database
err = s.UpdateAppConfigValues(ctx, imageName+"ImageType", fileType)
if err != nil {
return err
}
// Update the file type in the database
if err := s.UpdateImageType(imageName, fileType); err != nil {
return err
}
return nil
}
// InitDbConfig creates the default configuration values in the database if they do not exist,
// updates existing configurations if they differ from the default, and deletes any configurations
// that are not in the default configuration.
func (s *AppConfigService) InitDbConfig() error {
// Reflect to get the underlying value of DbConfig and its default configuration
defaultConfigReflectValue := reflect.ValueOf(defaultDbConfig)
defaultKeys := make(map[string]struct{})
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
dest, err := s.loadDbConfigInternal(ctx, s.db)
if err != nil {
return err
}
// Iterate over the fields of DbConfig
for i := 0; i < defaultConfigReflectValue.NumField(); i++ {
defaultConfigVar := defaultConfigReflectValue.Field(i).Interface().(model.AppConfigVariable)
s.dbConfig.Store(dest)
defaultKeys[defaultConfigVar.Key] = struct{}{}
return nil
}
var storedConfigVar model.AppConfigVariable
if err := s.db.First(&storedConfigVar, "key = ?", defaultConfigVar.Key).Error; err != nil {
// If the configuration does not exist, create it
if err := s.db.Create(&defaultConfigVar).Error; err != nil {
return err
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, tx)
return dest, err
}
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Load all configuration values from the database
// This loads all values in a single shot
var loaded []model.AppConfigVariable
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
defer queryCancel()
err := tx.
WithContext(queryCtx).
Find(&loaded).Error
if err != nil {
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
}
// Iterate through all values loaded from the database
for _, v := range loaded {
// Find the field in the struct whose "key" tag matches, then update that
err = dest.UpdateField(v.Key, v.Value, false)
// We ignore the case of fields that don't exist, as there may be leftover data in the database
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
}
}
return dest, nil
}
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
// First, start from the default configuration
dest := s.getDefaultDbConfig()
// Iterate through each field
rt := reflect.ValueOf(dest).Elem().Type()
rv := reflect.ValueOf(dest).Elem()
for i := range rt.NumField() {
field := rt.Field(i)
// Get the key and internal tag values
tagValue := strings.Split(field.Tag.Get("key"), ",")
key := tagValue[0]
isInternal := slices.Contains(tagValue, "internal")
// Internal fields are loaded from the database as they can't be set from the environment
if isInternal {
var value string
err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}).
Where("key = ?", key).
Select("value").
First(&value).Error
if err == nil {
rv.Field(i).FieldByName("Value").SetString(value)
}
continue
}
// Update existing configuration if it differs from the default
if storedConfigVar.Type != defaultConfigVar.Type || storedConfigVar.IsPublic != defaultConfigVar.IsPublic || storedConfigVar.IsInternal != defaultConfigVar.IsInternal || storedConfigVar.DefaultValue != defaultConfigVar.DefaultValue {
storedConfigVar.Type = defaultConfigVar.Type
storedConfigVar.IsPublic = defaultConfigVar.IsPublic
storedConfigVar.IsInternal = defaultConfigVar.IsInternal
storedConfigVar.DefaultValue = defaultConfigVar.DefaultValue
if err := s.db.Save(&storedConfigVar).Error; err != nil {
return err
}
envVarName := utils.CamelCaseToScreamingSnakeCase(key)
// Set the value if it's set
value, ok := os.LookupEnv(envVarName)
if ok {
rv.Field(i).FieldByName("Value").SetString(value)
}
}
// Delete any configurations not in the default keys
var allConfigVars []model.AppConfigVariable
if err := s.db.Find(&allConfigVars).Error; err != nil {
return err
}
for _, config := range allConfigVars {
if _, exists := defaultKeys[config.Key]; !exists {
if err := s.db.Delete(&config).Error; err != nil {
return err
}
}
}
return s.LoadDbConfigFromDb()
return dest, nil
}
// LoadDbConfigFromDb loads the configuration values from the database into the DbConfig struct.
func (s *AppConfigService) LoadDbConfigFromDb() error {
dbConfigReflectValue := reflect.ValueOf(s.DbConfig).Elem()
func (s *AppConfigService) initInstanceID(ctx context.Context) error {
// Check if the instance ID is already set
instanceID := s.GetDbConfig().InstanceID.Value
if instanceID != "" {
return nil
}
for i := 0; i < dbConfigReflectValue.NumField(); i++ {
dbConfigField := dbConfigReflectValue.Field(i)
currentConfigVar := dbConfigField.Interface().(model.AppConfigVariable)
var storedConfigVar model.AppConfigVariable
if err := s.db.First(&storedConfigVar, "key = ?", currentConfigVar.Key).Error; err != nil {
return err
}
newInstanceID, err := uuid.GenerateUUID()
if err != nil {
return fmt.Errorf("failed to generate new instance ID: %w", err)
}
if storedConfigVar.Value == "" && storedConfigVar.DefaultValue != "" {
storedConfigVar.Value = storedConfigVar.DefaultValue
}
dbConfigField.Set(reflect.ValueOf(storedConfigVar))
err = s.UpdateAppConfigValues(ctx, "instanceId", newInstanceID)
if err != nil {
return fmt.Errorf("failed to update instance ID in the database: %w", err)
}
return nil

View File

@@ -0,0 +1,471 @@
package service
import (
"sync/atomic"
"testing"
"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"
)
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
service := &AppConfigService{
dbConfig: atomic.Pointer[model.AppConfig]{},
}
service.dbConfig.Store(config)
return service
}
func TestLoadDbConfig(t *testing.T) {
t.Run("empty config table", func(t *testing.T) {
db := newDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
// Load the config
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be equal to default config
require.Equal(t, service.GetDbConfig(), service.getDefaultDbConfig())
})
t.Run("loads value from config table", func(t *testing.T) {
db := newDatabaseForTest(t)
// Populate the config table with some initial values
err := db.
Create([]model.AppConfigVariable{
// Overrides default value
{Key: "appName", Value: "Test App"},
{Key: "sessionDuration", Value: "5"},
// Does not have a default value
{Key: "smtpHost", Value: "example"},
}).
Error
require.NoError(t, err)
// Load the config
service := &AppConfigService{
db: db,
}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Values should match expected ones
expect := service.getDefaultDbConfig()
expect.AppName.Value = "Test App"
expect.SessionDuration.Value = "5"
expect.SmtpHost.Value = "example"
require.Equal(t, service.GetDbConfig(), expect)
})
t.Run("ignores unknown config keys", func(t *testing.T) {
db := newDatabaseForTest(t)
// Add an entry with a key that doesn't exist in the config struct
err := db.Create([]model.AppConfigVariable{
{Key: "__nonExistentKey", Value: "some value"},
{Key: "appName", Value: "TestApp"}, // This one should still be loaded
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// This should not fail, just ignore the unknown key
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
config := service.GetDbConfig()
require.Equal(t, "TestApp", config.AppName.Value)
})
t.Run("loading config multiple times", func(t *testing.T) {
db := newDatabaseForTest(t)
// Initial state
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "InitialApp"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
require.Equal(t, "InitialApp", service.GetDbConfig().AppName.Value)
// Update the database value
err = db.Model(&model.AppConfigVariable{}).
Where("key = ?", "appName").
Update("value", "UpdatedApp").Error
require.NoError(t, err)
// Load the config again, it should reflect the updated value
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
require.Equal(t, "UpdatedApp", service.GetDbConfig().AppName.Value)
})
t.Run("loads config from env when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Set environment variables for testing
t.Setenv("APP_NAME", "EnvTest App")
t.Setenv("SESSION_DURATION", "45")
// Enable UiConfigDisabled to load from env
common.EnvConfig.UiConfigDisabled = true
// Create database with config that should be ignored
db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// Load the config
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be loaded from env, not DB
config := service.GetDbConfig()
require.Equal(t, "EnvTest App", config.AppName.Value, "Should load appName from env")
require.Equal(t, "45", config.SessionDuration.Value, "Should load sessionDuration from env")
})
t.Run("ignores env vars when UiConfigDisabled is false", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Set environment variables that should be ignored
t.Setenv("APP_NAME", "EnvTest App")
t.Setenv("SESSION_DURATION", "45")
// Make sure UiConfigDisabled is false to load from DB
common.EnvConfig.UiConfigDisabled = false
// Create database with config values that should take precedence
db := newDatabaseForTest(t)
err := db.Create([]model.AppConfigVariable{
{Key: "appName", Value: "DB App"},
{Key: "sessionDuration", Value: "120"},
}).Error
require.NoError(t, err)
service := &AppConfigService{
db: db,
}
// Load the config
err = service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Config should be loaded from DB, not env
config := service.GetDbConfig()
require.Equal(t, "DB App", config.AppName.Value, "Should load appName from DB, not env")
require.Equal(t, "120", config.SessionDuration.Value, "Should load sessionDuration from DB, not env")
})
}
func TestUpdateAppConfigValues(t *testing.T) {
t.Run("update single value", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Update a single config value
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App")
require.NoError(t, err)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Test App", config.AppName.Value)
// Verify database was updated
var dbValue model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&dbValue).Error
require.NoError(t, err)
require.Equal(t, "Test App", dbValue.Value)
})
t.Run("update multiple values", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Update multiple config values
err = service.UpdateAppConfigValues(
t.Context(),
"appName", "Test App",
"sessionDuration", "30",
"smtpHost", "mail.example.com",
)
require.NoError(t, err)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Test App", config.AppName.Value)
require.Equal(t, "30", config.SessionDuration.Value)
require.Equal(t, "mail.example.com", config.SmtpHost.Value)
// Verify database was updated
var count int64
db.Model(&model.AppConfigVariable{}).Count(&count)
require.Equal(t, int64(3), count)
var appName, sessionDuration, smtpHost model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
require.NoError(t, err)
require.Equal(t, "Test App", appName.Value)
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
require.NoError(t, err)
require.Equal(t, "30", sessionDuration.Value)
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
require.NoError(t, err)
require.Equal(t, "mail.example.com", smtpHost.Value)
})
t.Run("empty value resets to default", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First change the value
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "30")
require.NoError(t, err)
require.Equal(t, "30", service.GetDbConfig().SessionDuration.Value)
// Now set it to empty which should use default value
err = service.UpdateAppConfigValues(t.Context(), "sessionDuration", "")
require.NoError(t, err)
require.Equal(t, "60", service.GetDbConfig().SessionDuration.Value) // Default value from getDefaultDbConfig
})
t.Run("error with odd number of arguments", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update with odd number of arguments
err = service.UpdateAppConfigValues(t.Context(), "appName", "Test App", "sessionDuration")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid number of arguments")
})
t.Run("error with invalid key", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update with invalid key
err = service.UpdateAppConfigValues(t.Context(), "nonExistentKey", "some value")
require.Error(t, err)
require.Contains(t, err.Error(), "invalid configuration key")
})
}
func TestUpdateAppConfig(t *testing.T) {
t.Run("updates configuration values from DTO", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Create update DTO
input := dto.AppConfigUpdateDto{
AppName: "Updated App Name",
SessionDuration: "120",
SmtpHost: "smtp.example.com",
SmtpPort: "587",
}
// Update config
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify returned updated variables
require.NotEmpty(t, updatedVars)
var foundAppName, foundSessionDuration, foundSmtpHost, foundSmtpPort bool
for _, v := range updatedVars {
switch v.Key {
case "appName":
require.Equal(t, "Updated App Name", v.Value)
foundAppName = true
case "sessionDuration":
require.Equal(t, "120", v.Value)
foundSessionDuration = true
case "smtpHost":
require.Equal(t, "smtp.example.com", v.Value)
foundSmtpHost = true
case "smtpPort":
require.Equal(t, "587", v.Value)
foundSmtpPort = true
}
}
require.True(t, foundAppName)
require.True(t, foundSessionDuration)
require.True(t, foundSmtpHost)
require.True(t, foundSmtpPort)
// Verify in-memory config was updated
config := service.GetDbConfig()
require.Equal(t, "Updated App Name", config.AppName.Value)
require.Equal(t, "120", config.SessionDuration.Value)
require.Equal(t, "smtp.example.com", config.SmtpHost.Value)
require.Equal(t, "587", config.SmtpPort.Value)
// Verify database was updated
var appName, sessionDuration, smtpHost, smtpPort model.AppConfigVariable
err = db.Where("key = ?", "appName").First(&appName).Error
require.NoError(t, err)
require.Equal(t, "Updated App Name", appName.Value)
err = db.Where("key = ?", "sessionDuration").First(&sessionDuration).Error
require.NoError(t, err)
require.Equal(t, "120", sessionDuration.Value)
err = db.Where("key = ?", "smtpHost").First(&smtpHost).Error
require.NoError(t, err)
require.Equal(t, "smtp.example.com", smtpHost.Value)
err = db.Where("key = ?", "smtpPort").First(&smtpPort).Error
require.NoError(t, err)
require.Equal(t, "587", smtpPort.Value)
})
t.Run("empty values reset to defaults", func(t *testing.T) {
db := newDatabaseForTest(t)
// Create a service with default config and modify some values
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// First set some non-default values
err = service.UpdateAppConfigValues(t.Context(),
"appName", "Custom App",
"sessionDuration", "120",
)
require.NoError(t, err)
// Create update DTO with empty values to reset to defaults
input := dto.AppConfigUpdateDto{
AppName: "", // Should reset to default "Pocket ID"
SessionDuration: "", // Should reset to default "60"
}
// Update config
updatedVars, err := service.UpdateAppConfig(t.Context(), input)
require.NoError(t, err)
// Verify returned updated variables (they should be empty strings in DB)
var foundAppName, foundSessionDuration bool
for _, v := range updatedVars {
switch v.Key {
case "appName":
require.Equal(t, "Pocket ID", v.Value) // Returns the default value
foundAppName = true
case "sessionDuration":
require.Equal(t, "60", v.Value) // Returns the default value
foundSessionDuration = true
}
}
require.True(t, foundAppName)
require.True(t, foundSessionDuration)
// Verify in-memory config was reset to defaults
config := service.GetDbConfig()
require.Equal(t, "Pocket ID", config.AppName.Value) // Default value
require.Equal(t, "60", config.SessionDuration.Value) // Default value
// Verify database was updated with empty values
for _, key := range []string{"appName", "sessionDuration"} {
var loaded model.AppConfigVariable
err = db.Where("key = ?", key).First(&loaded).Error
require.NoErrorf(t, err, "Failed to load DB value for key '%s'", key)
require.Emptyf(t, loaded.Value, "Loaded value for key '%s' is not empty", key)
}
})
t.Run("cannot update when UiConfigDisabled is true", func(t *testing.T) {
// Save the original state and restore it after the test
originalUiConfigDisabled := common.EnvConfig.UiConfigDisabled
defer func() {
common.EnvConfig.UiConfigDisabled = originalUiConfigDisabled
}()
// Disable UI config
common.EnvConfig.UiConfigDisabled = true
db := newDatabaseForTest(t)
service := &AppConfigService{
db: db,
}
err := service.LoadDbConfig(t.Context())
require.NoError(t, err)
// Try to update config
_, err = service.UpdateAppConfig(t.Context(), dto.AppConfigUpdateDto{
AppName: "Should Not Update",
})
// Should get a UiConfigDisabledError
require.Error(t, err)
var uiConfigDisabledErr *common.UiConfigDisabledError
require.ErrorAs(t, err, &uiConfigDisabledErr)
})
}

View File

@@ -1,9 +1,12 @@
package service
import (
"context"
"fmt"
"log"
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"
@@ -22,10 +25,10 @@ func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailSe
}
// Create creates a new audit log entry in the database
func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData) model.AuditLog {
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) model.AuditLog {
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil {
log.Printf("Failed to get IP location: %v\n", err)
log.Printf("Failed to get IP location: %v", err)
}
auditLog := model.AuditLog{
@@ -39,8 +42,12 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
}
// Save the audit log in the database
if err := s.db.Create(&auditLog).Error; err != nil {
log.Printf("Failed to create audit log: %v\n", err)
err = tx.
WithContext(ctx).
Create(&auditLog).
Error
if err != nil {
log.Printf("Failed to create audit log: %v", err)
return model.AuditLog{}
}
@@ -48,25 +55,42 @@ func (s *AuditLogService) Create(event model.AuditLogEvent, ipAddress, userAgent
}
// 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(ipAddress, userAgent, userID string) model.AuditLog {
createdAuditLog := s.Create(model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{})
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)
// Count the number of times the user has logged in from the same device
var count int64
err := s.db.Model(&model.AuditLog{}).Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).Count(&count).Error
err := tx.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).
Count(&count).
Error
if err != nil {
log.Printf("Failed to count audit logs: %v\n", err)
return createdAuditLog
}
// If the user hasn't logged in from the same device before and email notifications are enabled, send an email
if s.appConfigService.DbConfig.EmailLoginNotificationEnabled.Value == "true" && count <= 1 {
if s.appConfigService.GetDbConfig().EmailLoginNotificationEnabled.IsTrue() && count <= 1 {
// We use a background context here as this is running in a goroutine
//nolint:contextcheck
go func() {
var user model.User
s.db.Where("id = ?", userID).First(&user)
innerCtx := context.Background()
err := SendEmail(s.emailService, email.Address{
Name: user.Username,
// Note we don't use the transaction here because this is running in background
var user model.User
innerErr := s.db.
WithContext(innerCtx).
Where("id = ?", userID).
First(&user).
Error
if innerErr != nil {
log.Printf("Failed to load user: %v", innerErr)
}
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(),
Email: user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress,
@@ -75,8 +99,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
Device: s.DeviceStringFromUserAgent(userAgent),
DateTime: createdAuditLog.CreatedAt.UTC(),
})
if err != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, err)
if innerErr != nil {
log.Printf("Failed to send email to '%s': %v", user.Email, innerErr)
}
}()
}
@@ -85,9 +109,12 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ipAddress, userAgent, userID
}
// ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog
query := s.db.Model(&model.AuditLog{}).Where("user_id = ?", userID)
query := s.db.
WithContext(ctx).
Model(&model.AuditLog{}).
Where("user_id = ?", userID)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
return logs, pagination, err
@@ -97,3 +124,107 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
ua := userAgentParser.Parse(userAgent)
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) {
var logs []model.AuditLog
query := s.db.
WithContext(ctx).
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 != "" {
dialect := s.db.Name()
switch dialect {
case "sqlite":
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName)
case "postgres":
query = query.Where("data->>'clientName' = ?", filters.ClientName)
default:
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
}
}
if filters.Location != "" {
switch filters.Location {
case "external":
query = query.Where("country != 'Internal Network'")
case "internal":
query = query.Where("country = 'Internal Network'")
}
}
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
if err != nil {
return nil, pagination, err
}
return logs, pagination, nil
}
func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[string]string, err error) {
query := s.db.
WithContext(ctx).
Joins("User").
Model(&model.AuditLog{}).
Select("DISTINCT \"User\".id, \"User\".username").
Where("\"User\".username IS NOT NULL")
type Result struct {
ID string `gorm:"column:id"`
Username string `gorm:"column:username"`
}
var results []Result
if err := query.Find(&results).Error; err != nil {
return nil, fmt.Errorf("failed to query user IDs: %w", err)
}
users = make(map[string]string, len(results))
for _, result := range results {
users[result.ID] = result.Username
}
return users, nil
}
func (s *AuditLogService) ListClientNames(ctx context.Context) (clientNames []string, err error) {
dialect := s.db.Name()
query := s.db.
WithContext(ctx).
Model(&model.AuditLog{})
switch dialect {
case "sqlite":
query = query.
Select("DISTINCT json_extract(data, '$.clientName') AS client_name").
Where("json_extract(data, '$.clientName') IS NOT NULL")
case "postgres":
query = query.
Select("DISTINCT data->>'clientName' AS client_name").
Where("data->>'clientName' IS NOT NULL")
default:
return nil, fmt.Errorf("unsupported database dialect: %s", dialect)
}
type Result struct {
ClientName string `gorm:"column:client_name"`
}
var results []Result
if err := query.Find(&results).Error; err != nil {
return nil, fmt.Errorf("failed to query client IDs: %w", err)
}
clientNames = make([]string, len(results))
for i, result := range results {
clientNames[i] = result.ClientName
}
return clientNames, nil
}

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