Compare commits

..

101 Commits

Author SHA1 Message Date
Elias Schneider
4b086cebcd release: 1.9.0 2025-08-24 20:54:03 +02:00
Alessandro (Ale) Segala
1f3550c9bd fix: ensure SQLite has a writable temporary directory (#876)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:51 +02:00
dependabot[bot]
912008b048 chore(deps): bump golang.org/x/oauth2 from 0.26.0 to 0.27.0 in /backend in the go_modules group across 1 directory (#879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:30 +02:00
Elias Schneider
5ad8b03831 chore(translations): update translations via Crowdin (#878) 2025-08-24 20:42:58 +02:00
Elias Schneider
c1e515a05f ci/cd: use matrix for e2e tests 2025-08-24 20:35:30 +02:00
Elias Schneider
654593b4b6 chore(migrations): use TEXT instead of VARCHAR for client ID 2025-08-24 20:22:06 +02:00
Elias Schneider
8999173aa0 ci/cd: fix playwright browsers not installed 2025-08-24 20:16:57 +02:00
Elias Schneider
10b087640f tests: fix postgres e2e tests (#877) 2025-08-24 19:15:26 +02:00
Elias Schneider
d0392d25ed fix: sort order incorrect for apps when using postgres 2025-08-24 19:08:33 +02:00
Elias Schneider
2ffc6ba42a fix: don't force uuid for client id in postgres 2025-08-24 18:29:41 +02:00
Elias Schneider
c114a2edaa feat: support automatic db migration rollbacks (#874) 2025-08-24 16:56:28 +02:00
Elias Schneider
63db4d5120 chore(migrations): add postgres down migration to 20250822000000 2025-08-24 15:30:18 +02:00
Elias Schneider
d8c73ed472 release: 1.8.1 2025-08-24 15:12:14 +02:00
Elias Schneider
5971bfbfa6 fix: migration clears allowed users groups 2025-08-24 15:05:45 +02:00
Alessandro (Ale) Segala
29eacd6424 chore: update issue template (#870) 2025-08-24 14:35:39 +02:00
Elias Schneider
21ca87be38 chore(translations): update translations via Crowdin (#860) 2025-08-24 14:34:44 +02:00
Alessandro (Ale) Segala
1283314f77 fix: wrong column type for reauthentication tokens in Postgres (#869) 2025-08-24 14:34:29 +02:00
Elias Schneider
9c54e2e6b0 release: 1.8.0 2025-08-23 18:57:19 +02:00
Elias Schneider
a5efb95065 feat: allow custom client IDs (#864) 2025-08-23 18:41:05 +02:00
Elias Schneider
625f235740 fix: enable foreign key check for sqlite (#863)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-08-23 17:54:51 +02:00
Elias Schneider
2c122d413d refactor: run formatter 2025-08-23 17:46:59 +02:00
Elias Schneider
fc0c99a232 fix: oidc client advanced options color 2025-08-23 17:40:58 +02:00
Elias Schneider
24e274200f fix: ferated identities can't be cleared 2025-08-23 17:40:06 +02:00
Elias Schneider
0aab3f3c7a fix: authorization can't be revoked 2025-08-23 17:28:27 +02:00
Zeedif
182d809028 feat(signup): add default user groups and claims for new users (#812)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 14:25:02 +02:00
Elias Schneider
c51265dafb chore(translations): change alternative sign in methods text 2025-08-22 13:06:38 +02:00
Robert Mang
0cb039d35d feat: add option to OIDC client to require re-authentication (#747)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-22 08:56:40 +02:00
Alessandro (Ale) Segala
7ab0fd3028 fix: for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date (#855) 2025-08-22 08:02:56 +02:00
Maxime R
49f0fa423c chore: strip debug symbol from backend binary (#856) 2025-08-21 15:46:45 +00:00
Elias Schneider
61e63e411d chore(translations): update translations via Crowdin (#850) 2025-08-20 17:07:08 -05:00
Alessandro (Ale) Segala
9339e88a5a fix: move audit log call before TX is committed (#854) 2025-08-20 17:01:53 -05:00
Elias Schneider
fe003b927c fix: delete webauthn session after login to prevent replay attacks 2025-08-20 15:49:19 +02:00
Kyle Mendell
f5b5b1bd85 tests: use proper async calls for cleanupBackend function (#846) 2025-08-20 10:38:03 +02:00
James18232
d28bfac81f feat: login code font change (#851)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-19 14:10:57 +00:00
Elias Schneider
b04e3e8ecf chore(translations): update translations via Crowdin (#848) 2025-08-19 12:03:51 +02:00
Kyle Mendell
d77d8eb068 chore(translations): add Korean files 2025-08-18 14:53:19 -05:00
Elias Schneider
7cd88aca25 chore(translations): update translations via Crowdin (#841) 2025-08-18 11:21:27 -05:00
Gergő Gutyina
b5e6371eaa fix(deps): bump rollup from 4.45.3 to 4.46.3 (#845) 2025-08-18 07:44:42 -05:00
github-actions[bot]
544b98c1d0 chore: update AAGUIDs (#844)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-17 22:52:58 -05:00
Elias Schneider
3188e92257 feat: display all accessible oidc clients in the dashboard (#832)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-17 22:47:34 +02:00
Elias Schneider
3fa2f9a162 chore(translations): update translations via Crowdin (#821) 2025-08-16 22:50:21 -05:00
James18232
7b1f6b8857 fix: ignore client secret if client is public (#836)
Co-authored-by: James18232 <80368042+James18232@users.noreply.github.com>
2025-08-16 17:55:32 +02:00
Alessandro (Ale) Segala
17d8893bdb chore: update deps and Go 1.25 (#833) 2025-08-14 22:33:27 -05:00
Elias Schneider
0e44f245af fix: non admin users can't revoke oidc client but see edit link 2025-08-12 09:46:15 +02:00
github-actions[bot]
824e8f1a0f chore: update AAGUIDs (#826)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-10 21:33:29 -05:00
Elias Schneider
6e4d2a4a33 release: 1.7.0 2025-08-10 20:01:03 +02:00
Elias Schneider
6c65bd34cd chore(translations): update translations via Crowdin (#820) 2025-08-10 19:50:36 +02:00
Kyle Mendell
7bfe4834d0 chore: switch from npm to pnpm (#786)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 12:16:30 -05:00
Kyle Mendell
484c2f6ef2 feat: user application dashboard (#727)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-10 15:56:03 +00:00
Elias Schneider
87956ea725 chore(translations): update translations via Crowdin (#819) 2025-08-10 10:18:30 -05:00
Elias Schneider
32dd403038 chore(translations): update translations via Crowdin (#817) 2025-08-10 14:49:24 +02:00
Elias Schneider
4d59e72866 fix: custom claims input suggestions instantly close after opening 2025-08-08 15:11:44 +02:00
Elias Schneider
9ac5d51187 fix: authorization animation not working 2025-08-08 12:23:32 +02:00
Elias Schneider
5a031f5d1b refactor: use reflection to mark file based env variables (#815) 2025-08-07 20:41:00 +02:00
Alessandro (Ale) Segala
535bc9f46b chore: additional logs for database connections (#813) 2025-08-06 18:04:25 +02:00
Kyle Mendell
f0c144c51c fix: admins can not delete or disable their own account 2025-08-05 16:14:25 -05:00
Elias Schneider
61e4ea45fb chore(translations): update translations via Crowdin (#811) 2025-08-05 15:56:45 -05:00
Etienne
06e1656923 feat: add robots.txt to block indexing (#806) 2025-08-02 18:30:50 +00:00
Alessandro (Ale) Segala
0a3b1c6530 feat: support reading secret env vars from _FILE (#799)
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-07-30 11:59:25 -05:00
Kyle Mendell
d479817b6a feat: add support for code_challenge_methods_supported (#794) 2025-07-29 17:34:49 -05:00
Elias Schneider
01bf31d23d chore(translations): update translations via Crowdin (#791) 2025-07-27 20:21:37 -05:00
Alessandro (Ale) Segala
42a861d206 refactor: complete conversion of log calls to slog (#787) 2025-07-27 04:34:23 +00:00
Alessandro (Ale) Segala
78266e3e4c feat: Support OTel and JSON for logs (via log/slog) (#760)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-27 01:03:52 +00:00
Alessandro (Ale) Segala
c8478d75be fix: delete WebAuthn registration session after use (#783) 2025-07-26 18:45:54 -05:00
Elias Schneider
28d93b00a3 chore(translations): update translations via Crowdin (#785) 2025-07-26 16:37:42 -05:00
Kyle Mendell
12a7a6a5c5 chore: update Vietnamese display name 2025-07-26 15:33:36 -05:00
Elias Schneider
a6d5071724 chore(translations): update translations via Crowdin (#782) 2025-07-25 15:48:52 -05:00
Elias Schneider
cebe2242b9 chore(translations): update translations via Crowdin (#779) 2025-07-24 20:28:07 -05:00
Kyle Mendell
56ee7d946f chore: fix federated credentials type error 2025-07-24 20:22:34 -05:00
Kyle Mendell
f3c6521f2b chore: update dependencies and fix zod/4 import path 2025-07-24 20:16:17 -05:00
Kyle Mendell
ffed465f09 chore: update dependencies and fix zod/4 import path 2025-07-24 20:14:25 -05:00
Kyle Mendell
c359b5be06 chore: rename glass-row-item to passkey-row 2025-07-24 19:50:27 -05:00
Elias Schneider
e9a023bb71 chore(translations): update translations via Crowdin (#778)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-07-24 19:35:16 -05:00
Kyle Mendell
60f0b28076 chore(transaltions): add Vietnamese files 2025-07-24 10:11:01 -05:00
Alessandro (Ale) Segala
d541c9ab4a fix: set input type 'email' for email-based login (#776) 2025-07-23 12:39:50 -05:00
dependabot[bot]
024ed53022 chore(deps): bump axios from 1.10.0 to 1.11.0 in /frontend in the npm_and_yarn group across 1 directory (#777)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-23 12:38:00 -05:00
Elias Schneider
2c78bd1b46 chore(translations): update translations via Crowdin (#767) 2025-07-22 15:08:04 -05:00
dependabot[bot]
5602d79611 chore(deps): bump form-data from 4.0.1 to 4.0.4 in /frontend in the npm_and_yarn group across 1 directory (#771)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-07-22 15:07:43 -05:00
Kyle Mendell
51b73c9c31 chore(translations): add Ukrainian files 2025-07-21 15:56:59 -05:00
Elias Schneider
10f0580a43 chore(translations): update translations via Crowdin (#763) 2025-07-21 07:32:57 -05:00
ItalyPaleAle
a1488565ea release: 1.6.4 2025-07-21 07:44:25 +02:00
Alessandro (Ale) Segala
35d5f887ce fix: migration fails on postgres (#762) 2025-07-20 22:36:22 -07:00
Kyle Mendell
4c76de45ed chore: remove labels from issue templates 2025-07-20 22:51:02 -05:00
Kyle Mendell
68fc9c0659 release: 1.6.3 2025-07-20 22:35:35 -05:00
Kyle Mendell
2952b15755 fix: show rename and delete buttons for passkeys without hovering over the row 2025-07-20 19:09:06 -05:00
Kyle Mendell
ef1d599662 fix: use user-agent for identifying known device signins 2025-07-20 19:02:17 -05:00
Kyle Mendell
4e49d3932a chore: upgrade dependencies (#752) 2025-07-14 23:36:36 -05:00
Elias Schneider
86d3c08494 chore(translations): update translations via Crowdin (#750) 2025-07-14 13:15:33 -05:00
Alessandro (Ale) Segala
7b4ccd1f30 fix: ensure user inputs are normalized (#724)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-07-13 16:15:57 +00:00
Kyle Mendell
f145903eb0 chore: use correct svelte 5 syntax for signup token modal 2025-07-11 22:53:01 -05:00
Kyle Mendell
d3bc1797b6 fix: use object-contain for images on oidc-client list 2025-07-11 22:46:40 -05:00
Kyle Mendell
db94f81937 chore: use issue types for new issues 2025-07-11 22:25:11 -05:00
Kyle Mendell
b03e91b653 fix: allow passkey names up to 50 characters 2025-07-11 22:10:59 -05:00
Kyle Mendell
505bdcb8ba release: 1.6.2 2025-07-09 16:56:34 -05:00
Kyle Mendell
f103a54790 fix: ensure confirmation dialog shows on top of other components 2025-07-09 16:50:01 -05:00
Alessandro (Ale) Segala
e1de593dcd fix: login failures on Postgres when IP is null (#737) 2025-07-09 08:45:07 -05:00
Elias Schneider
45f42772b1 chore(translations): update translations via Crowdin (#730) 2025-07-07 20:06:52 -05:00
XLion
98152640b1 chore(translations): Fix inconsistent punctuation marks for the language name of zh-TW (#731) 2025-07-07 12:54:45 +00:00
github-actions[bot]
04e235e805 chore: update AAGUIDs (#729)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-07-06 21:04:32 -05:00
Elias Schneider
ae737dddaa release: 1.6.1 2025-07-06 22:50:33 +02:00
Elias Schneider
f565c702e5 ci/cd: use latest-distroless tag for latest distroless images 2025-07-06 22:48:55 +02:00
187 changed files with 10544 additions and 7274 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,17 +21,22 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: "npm" cache: 'pnpm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: pnpm-lock.yaml
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v5
with: with:
go-version-file: "backend/go.mod" go-version-file: 'backend/go.mod'
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -54,12 +59,11 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: frontend run: pnpm install --frozen-lockfile
run: npm ci
- name: Build frontend - name: Build frontend
working-directory: frontend working-directory: frontend
run: npm run build run: pnpm run build
- name: Build binaries - name: Build binaries
run: sh scripts/development/build-binaries.sh --docker-only run: sh scripts/development/build-binaries.sh --docker-only
@@ -85,12 +89,12 @@ jobs:
- name: Container image attestation - name: Container image attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.build-push-image.outputs.digest }} subject-digest: ${{ steps.build-push-image.outputs.digest }}
push-to-registry: true push-to-registry: true
- name: Container image attestation (distroless) - name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }} subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true push-to-registry: true

View File

@@ -45,39 +45,54 @@ jobs:
path: /tmp/docker-image.tar path: /tmp/docker-image.tar
retention-days: 1 retention-days: 1
test-sqlite: test:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions: permissions:
contents: read contents: read
actions: write actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: "npm" cache: "pnpm"
cache-dependency-path: frontend/package-lock.json cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v3 uses: actions/cache@v3
id: playwright-cache id: playwright-cache
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }} key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: | - name: Cache PostgreSQL Docker image
${{ runner.os }}-playwright- if: matrix.db == 'postgres'
uses: actions/cache@v3
- name: Download Docker image artifact id: postgres-cache
uses: actions/download-artifact@v4
with: with:
name: docker-image path: /tmp/postgres-image.tar
path: /tmp key: postgres-17-${{ runner.os }}
- name: Load Docker image - name: Pull and save PostgreSQL image
run: docker load -i /tmp/docker-image.tar if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
run: |
docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image from cache
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image - name: Cache LLDAP Docker image
uses: actions/cache@v3 uses: actions/cache@v3
id: lldap-cache id: lldap-cache
@@ -94,31 +109,45 @@ jobs:
- name: Load LLDAP image from cache - name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true' if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar run: docker load < /tmp/lldap-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies - name: Install test dependencies
working-directory: ./tests run: pnpm --filter pocket-id-tests install --frozen-lockfile
run: npm ci
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./tests working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container (sqlite) with LDAP
- name: Run Docker Container with Sqlite DB and LDAP if: matrix.db == 'sqlite'
working-directory: ./tests/setup working-directory: ./tests/setup
run: | run: |
docker compose up -d docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log & docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Docker Container (postgres) with LDAP
if: matrix.db == 'postgres'
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./tests working-directory: ./tests
run: npx playwright test run: pnpm exec playwright test
- name: Upload Test Report - name: Upload Test Report
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: playwright-report-sqlite name: playwright-report-${{ matrix.db }}
path: tests/.report path: tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
@@ -127,111 +156,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: backend-sqlite name: backend-${{ matrix.db }}
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: "npm"
cache-dependency-path: frontend/package-lock.json
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Cache PostgreSQL Docker image
uses: actions/cache@v3
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: steps.postgres-cache.outputs.cache-hit != 'true'
run: |
docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image from cache
if: steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image
uses: actions/cache@v3
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies
working-directory: ./tests
run: npm ci
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: npx playwright install --with-deps chromium
- name: Run Docker Container with Postgres DB and LDAP
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./tests
run: npx playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-postgres
path: frontend/tests/.report
include-hidden-files: true
retention-days: 15
- name: Upload Backend Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-postgres
path: /tmp/backend.log path: /tmp/backend.log
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15

View File

@@ -3,7 +3,7 @@ name: Release
on: on:
push: push:
tags: tags:
- "v*.*.*" - 'v*.*.*'
jobs: jobs:
build: build:
@@ -16,15 +16,19 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: "npm" cache: 'pnpm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: pnpm-lock.yaml
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version-file: "backend/go.mod" go-version-file: 'backend/go.mod'
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -57,16 +61,16 @@ jobs:
with: with:
images: | images: |
${{ env.DOCKER_IMAGE_NAME }} ${{ env.DOCKER_IMAGE_NAME }}
flavor: |
suffix=-distroless,onlatest=true
tags: | tags: |
type=semver,pattern={{version}}-distroless,prefix=v type=semver,pattern={{version}},prefix=v
type=semver,pattern={{major}}.{{minor}}-distroless,prefix=v type=semver,pattern={{major}}.{{minor}},prefix=v
type=semver,pattern={{major}}-distroless,prefix=v type=semver,pattern={{major}},prefix=v
- name: Install frontend dependencies - name: Install frontend dependencies
working-directory: frontend run: pnpm --filter pocket-id-frontend install --frozen-lockfile
run: npm ci
- name: Build frontend - name: Build frontend
working-directory: frontend run: pnpm --filter pocket-id-frontend build
run: npm run build
- name: Build binaries - name: Build binaries
run: sh scripts/development/build-binaries.sh run: sh scripts/development/build-binaries.sh
- name: Build and push container image - name: Build and push container image
@@ -92,17 +96,17 @@ jobs:
- name: Binary attestation - name: Binary attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-path: "backend/.bin/pocket-id-**" subject-path: 'backend/.bin/pocket-id-**'
- name: Container image attestation - name: Container image attestation
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.container-build-push.outputs.digest }} subject-digest: ${{ steps.container-build-push.outputs.digest }}
push-to-registry: true push-to-registry: true
- name: Container image attestation (distroless) - name: Container image attestation (distroless)
uses: actions/attest-build-provenance@v2 uses: actions/attest-build-provenance@v2
with: with:
subject-name: "${{ env.DOCKER_IMAGE_NAME }}" subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }} subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
push-to-registry: true push-to-registry: true
- name: Upload binaries to release - name: Upload binaries to release
@@ -119,6 +123,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Mark release as published - name: Mark release as published
run: gh release edit ${{ github.ref_name }} --draft=false run: gh release edit ${{ github.ref_name }} --draft=false

View File

@@ -4,21 +4,21 @@ on:
push: push:
branches: [main] branches: [main]
paths: paths:
- "frontend/src/**" - 'frontend/src/**'
- ".github/svelte-check-matcher.json" - '.github/svelte-check-matcher.json'
- "frontend/package.json" - 'frontend/package.json'
- "frontend/package-lock.json" - 'frontend/package-lock.json'
- "frontend/tsconfig.json" - 'frontend/tsconfig.json'
- "frontend/svelte.config.js" - 'frontend/svelte.config.js'
pull_request: pull_request:
branches: [main] branches: [main]
paths: paths:
- "frontend/src/**" - 'frontend/src/**'
- ".github/svelte-check-matcher.json" - '.github/svelte-check-matcher.json'
- "frontend/package.json" - 'frontend/package.json'
- "frontend/package-lock.json" - 'frontend/package-lock.json'
- "frontend/tsconfig.json" - 'frontend/tsconfig.json'
- "frontend/svelte.config.js" - 'frontend/svelte.config.js'
workflow_dispatch: workflow_dispatch:
jobs: jobs:
@@ -36,24 +36,28 @@ jobs:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
cache: "npm" cache: 'pnpm'
cache-dependency-path: frontend/package-lock.json cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
working-directory: frontend run: pnpm --filter pocket-id-frontend install --frozen-lockfile
run: npm ci
- name: Build Pocket ID Frontend - name: Build Pocket ID Frontend
working-directory: frontend working-directory: frontend
run: npm run build run: pnpm --filter pocket-id-frontend build
- name: Add svelte-check problem matcher - name: Add svelte-check problem matcher
run: echo "::add-matcher::.github/svelte-check-matcher.json" run: echo "::add-matcher::.github/svelte-check-matcher.json"
- name: Run svelte-check - name: Run svelte-check
working-directory: frontend working-directory: frontend
run: npm run check run: pnpm --filter pocket-id-frontend check

View File

@@ -1 +1 @@
1.6.0 1.9.0

View File

@@ -1,3 +1,90 @@
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.1...v) (2025-08-24)
### Features
* support automatic db migration rollbacks ([#874](https://github.com/pocket-id/pocket-id/issues/874)) ([c114a2e](https://github.com/pocket-id/pocket-id/commit/c114a2edaae4c007c75c34c02e8b0bb011845cae))
### Bug Fixes
* don't force uuid for client id in postgres ([2ffc6ba](https://github.com/pocket-id/pocket-id/commit/2ffc6ba42af4742a13b77543142b66b3e826ab88))
* ensure SQLite has a writable temporary directory ([#876](https://github.com/pocket-id/pocket-id/issues/876)) ([1f3550c](https://github.com/pocket-id/pocket-id/commit/1f3550c9bd3aafd3bd2272ef47f3ed8736037d81))
* sort order incorrect for apps when using postgres ([d0392d2](https://github.com/pocket-id/pocket-id/commit/d0392d25edcaa5f3c7da2aad70febf63b47763fa))
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.0...v) (2025-08-24)
### Bug Fixes
* migration clears allowed users groups ([5971bfb](https://github.com/pocket-id/pocket-id/commit/5971bfbfa66ecfebf2b1c08d34fcbd8c18cdc046))
* wrong column type for reauthentication tokens in Postgres ([#869](https://github.com/pocket-id/pocket-id/issues/869)) ([1283314](https://github.com/pocket-id/pocket-id/commit/1283314f776a0ba43be7d796e7e2243e31f860de))
## [](https://github.com/pocket-id/pocket-id/compare/v1.7.0...v) (2025-08-23)
### Features
* add option to OIDC client to require re-authentication ([#747](https://github.com/pocket-id/pocket-id/issues/747)) ([0cb039d](https://github.com/pocket-id/pocket-id/commit/0cb039d35d49206011064e622f3bfd3d8f88720f))
* allow custom client IDs ([#864](https://github.com/pocket-id/pocket-id/issues/864)) ([a5efb95](https://github.com/pocket-id/pocket-id/commit/a5efb9506582884c70b9b1fd737ebdd44b101b47))
* display all accessible oidc clients in the dashboard ([#832](https://github.com/pocket-id/pocket-id/issues/832)) ([3188e92](https://github.com/pocket-id/pocket-id/commit/3188e92257afcaf7a16dd418e4c40626d7e1d034))
* login code font change ([#851](https://github.com/pocket-id/pocket-id/issues/851)) ([d28bfac](https://github.com/pocket-id/pocket-id/commit/d28bfac81fc24ee79e4896538a616f0a89ab30a5))
* **signup:** add default user groups and claims for new users ([#812](https://github.com/pocket-id/pocket-id/issues/812)) ([182d809](https://github.com/pocket-id/pocket-id/commit/182d8090286f9953171c6c410283be679889aca7))
### Bug Fixes
* authorization can't be revoked ([0aab3f3](https://github.com/pocket-id/pocket-id/commit/0aab3f3c7ad8c1b14939de3ded60c9f201eab8fc))
* delete webauthn session after login to prevent replay attacks ([fe003b9](https://github.com/pocket-id/pocket-id/commit/fe003b927ce7772692439992860c804de89ce424))
* **deps:** bump rollup from 4.45.3 to 4.46.3 ([#845](https://github.com/pocket-id/pocket-id/issues/845)) ([b5e6371](https://github.com/pocket-id/pocket-id/commit/b5e6371eaaf3d9e85d8b05c457487c4425fa8381))
* enable foreign key check for sqlite ([#863](https://github.com/pocket-id/pocket-id/issues/863)) ([625f235](https://github.com/pocket-id/pocket-id/commit/625f23574001ebd7074b8d98d448a2811847be16))
* ferated identities can't be cleared ([24e2742](https://github.com/pocket-id/pocket-id/commit/24e274200fe4002d01c58cc3fa74094b598d7599))
* for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date ([#855](https://github.com/pocket-id/pocket-id/issues/855)) ([7ab0fd3](https://github.com/pocket-id/pocket-id/commit/7ab0fd30286e6b67b5ce586484d82a20c42b471d))
* ignore client secret if client is public ([#836](https://github.com/pocket-id/pocket-id/issues/836)) ([7b1f6b8](https://github.com/pocket-id/pocket-id/commit/7b1f6b88572bac1f3e838a9e904917fbd5fbdf61))
* move audit log call before TX is committed ([#854](https://github.com/pocket-id/pocket-id/issues/854)) ([9339e88](https://github.com/pocket-id/pocket-id/commit/9339e88a5a26ff77a5e40149cbb1a5b339b7ec6a))
* non admin users can't revoke oidc client but see edit link ([0e44f24](https://github.com/pocket-id/pocket-id/commit/0e44f245afcdf8179bf619613ca9ef4bffa176ca))
* oidc client advanced options color ([fc0c99a](https://github.com/pocket-id/pocket-id/commit/fc0c99a232b0efb1a5b5d2c551102418b1080293))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.4...v) (2025-08-10)
### Features
* add robots.txt to block indexing ([#806](https://github.com/pocket-id/pocket-id/issues/806)) ([06e1656](https://github.com/pocket-id/pocket-id/commit/06e1656923eb2f4531be497716f9147c09d60b65))
* add support for `code_challenge_methods_supported` ([#794](https://github.com/pocket-id/pocket-id/issues/794)) ([d479817](https://github.com/pocket-id/pocket-id/commit/d479817b6a7ca4807b5de500b3ba713d436b0770))
* Support OTel and JSON for logs (via log/slog) ([#760](https://github.com/pocket-id/pocket-id/issues/760)) ([78266e3](https://github.com/pocket-id/pocket-id/commit/78266e3e4cab2b23249c3baf20f4387d00eebd9e))
* support reading secret env vars from _FILE ([#799](https://github.com/pocket-id/pocket-id/issues/799)) ([0a3b1c6](https://github.com/pocket-id/pocket-id/commit/0a3b1c653050f2237d30ec437c5de88baa704a25))
* user application dashboard ([#727](https://github.com/pocket-id/pocket-id/issues/727)) ([484c2f6](https://github.com/pocket-id/pocket-id/commit/484c2f6ef20efc1fade1a41e2aeace54c7bb4f1b))
### Bug Fixes
* admins can not delete or disable their own account ([f0c144c](https://github.com/pocket-id/pocket-id/commit/f0c144c51c635bc348222a00d3bc88bc4e0711ef))
* authorization animation not working ([9ac5d51](https://github.com/pocket-id/pocket-id/commit/9ac5d5118710cad59c8c4ce7cef7ab09be3de664))
* custom claims input suggestions instantly close after opening ([4d59e72](https://github.com/pocket-id/pocket-id/commit/4d59e7286666480e20c728787a95e82513509240))
* delete WebAuthn registration session after use ([#783](https://github.com/pocket-id/pocket-id/issues/783)) ([c8478d7](https://github.com/pocket-id/pocket-id/commit/c8478d75bed7295625cd3cf62ef46fcd95902410))
* set input type 'email' for email-based login ([#776](https://github.com/pocket-id/pocket-id/issues/776)) ([d541c9a](https://github.com/pocket-id/pocket-id/commit/d541c9ab4af8d7283891a80f886dd5d4ebc52f53))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.3...v) (2025-07-21)
### Bug Fixes
* migration fails on postgres ([#762](https://github.com/pocket-id/pocket-id/issues/762)) ([35d5f88](https://github.com/pocket-id/pocket-id/commit/35d5f887ce7c88933d7e4c2f0acd2aeedd18c214))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.2...v) (2025-07-21)
### Bug Fixes
* allow passkey names up to 50 characters ([b03e91b](https://github.com/pocket-id/pocket-id/commit/b03e91b6530c2393ad20ac49aa2cb2b4962651b2))
* ensure user inputs are normalized ([#724](https://github.com/pocket-id/pocket-id/issues/724)) ([7b4ccd1](https://github.com/pocket-id/pocket-id/commit/7b4ccd1f306f4882c52fe30133fcda114ef0d18b))
* show rename and delete buttons for passkeys without hovering over the row ([2952b15](https://github.com/pocket-id/pocket-id/commit/2952b1575542ecd0062fe740e2d6a3caad05190d))
* use object-contain for images on oidc-client list ([d3bc179](https://github.com/pocket-id/pocket-id/commit/d3bc1797b65ec8bc9201c55d06f3612093f3a873))
* use user-agent for identifying known device signins ([ef1d599](https://github.com/pocket-id/pocket-id/commit/ef1d5996624fc534190f80a26f2c48bbad206f49))
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.1...v) (2025-07-09)
### Bug Fixes
* ensure confirmation dialog shows on top of other components ([f103a54](https://github.com/pocket-id/pocket-id/commit/f103a547904070c5b192e519c8b5a8fed9d80e96))
* login failures on Postgres when IP is null ([#737](https://github.com/pocket-id/pocket-id/issues/737)) ([e1de593](https://github.com/pocket-id/pocket-id/commit/e1de593dcd30b7b04da3b003455134992b702595))
## [](https://github.com/pocket-id/pocket-id/compare/v1.5.0...v) (2025-07-06) ## [](https://github.com/pocket-id/pocket-id/compare/v1.5.0...v) (2025-07-06)

View File

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

@@ -5,21 +5,27 @@ ARG BUILD_TAGS=""
# Stage 1: Build Frontend # Stage 1: Build Frontend
FROM node:22-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
RUN corepack enable
WORKDIR /build WORKDIR /build
COPY ./frontend/package*.json ./
RUN npm ci COPY pnpm-workspace.yaml pnpm-lock.yaml ./
COPY ./frontend ./ COPY frontend/package.json ./frontend/
RUN BUILD_OUTPUT_PATH=dist npm run build RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
COPY ./frontend ./frontend/
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.24-alpine AS backend-builder FROM golang:1.25-alpine AS backend-builder
ARG BUILD_TAGS ARG BUILD_TAGS
WORKDIR /build WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./ COPY ./backend/go.mod ./backend/go.sum ./
RUN go mod download RUN go mod download
COPY ./backend ./ COPY ./backend ./
COPY --from=frontend-builder /build/dist ./frontend/dist COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
COPY .version .version COPY .version .version
WORKDIR /build/cmd WORKDIR /build/cmd
@@ -30,7 +36,7 @@ RUN VERSION=$(cat /build/.version) \
-tags "${BUILD_TAGS}" \ -tags "${BUILD_TAGS}" \
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \ -ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
-trimpath \ -trimpath \
-o /build/pocket-id-backend \ -o /build/pocket-id \
. .
# Stage 3: Production Image # Stage 3: Production Image
@@ -39,7 +45,7 @@ WORKDIR /app
RUN apk add --no-cache curl su-exec RUN apk add --no-cache curl su-exec
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id COPY --from=backend-builder /build/pocket-id /app/pocket-id
COPY ./scripts/docker /app/docker COPY ./scripts/docker /app/docker
RUN chmod +x /app/pocket-id && \ RUN chmod +x /app/pocket-id && \

View File

@@ -1,98 +1,108 @@
module github.com/pocket-id/pocket-id/backend module github.com/pocket-id/pocket-id/backend
go 1.24.0 go 1.25
require ( require (
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.2 github.com/cenkalti/backoff/v5 v5.0.3
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2 github.com/disintegration/imaging v1.6.2
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3 github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.7.0 github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-gonic/gin v1.10.0 github.com/gin-gonic/gin v1.10.1
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.15.0 github.com/go-co-op/gocron/v2 v2.16.3
github.com/go-ldap/ldap/v3 v3.4.10 github.com/go-ldap/ldap/v3 v3.4.10
github.com/go-playground/validator/v10 v10.25.0 github.com/go-playground/validator/v10 v10.27.0
github.com/go-webauthn/webauthn v0.11.2 github.com/go-webauthn/webauthn v0.11.2
github.com/golang-migrate/migrate/v4 v4.18.2 github.com/golang-migrate/migrate/v4 v4.18.3
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 github.com/lestrrat-go/httprc/v3 v3.0.0
github.com/lestrrat-go/jwx/v3 v3.0.1 github.com/lestrrat-go/jwx/v3 v3.0.10
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
github.com/samber/slog-gin v1.15.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.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/contrib/instrumentation/net/http/otelhttp v0.54.0
go.opentelemetry.io/otel v1.35.0 go.opentelemetry.io/otel v1.37.0
go.opentelemetry.io/otel/metric v1.35.0 go.opentelemetry.io/otel/log v0.13.0
go.opentelemetry.io/otel/metric v1.37.0
go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/sdk v1.35.0
go.opentelemetry.io/otel/sdk/log v0.10.0
go.opentelemetry.io/otel/sdk/metric v1.35.0 go.opentelemetry.io/otel/sdk/metric v1.35.0
go.opentelemetry.io/otel/trace v1.35.0 go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/crypto v0.37.0 golang.org/x/crypto v0.41.0
golang.org/x/image v0.24.0 golang.org/x/image v0.30.0
golang.org/x/time v0.9.0 golang.org/x/text v0.28.0
gorm.io/driver/postgres v1.5.11 golang.org/x/time v0.12.0
gorm.io/gorm v1.25.12 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.1
) )
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.12.10 // indirect github.com/bytedance/sonic v1.14.0 // indirect
github.com/bytedance/sonic/loader v0.2.3 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/disintegration/gift v1.1.2 // indirect github.com/disintegration/gift v1.1.2 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect github.com/gabriel-vasile/mimetype v1.4.9 // indirect
github.com/gin-contrib/sse v1.0.0 // indirect github.com/gin-contrib/sse v1.1.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-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-logr/logr v1.4.2 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-webauthn/x v0.1.16 // indirect github.com/go-webauthn/x v0.1.23 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/go-tpm v0.9.3 // indirect github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/pgx/v5 v5.7.2 // indirect github.com/jackc/pgx/v5 v5.7.5 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/jonboulle/clockwork v0.5.0 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect
github.com/lestrrat-go/blackmagic v1.0.3 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.10.9 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.24 // indirect github.com/mattn/go-sqlite3 v1.14.24 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.22.0 // indirect github.com/prometheus/client_golang v1.22.0 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
@@ -103,7 +113,8 @@ require (
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.0 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.6 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/fastjson v1.6.4 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.1.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
@@ -118,23 +129,21 @@ require (
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.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/stdoutmetric v1.35.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace 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.opentelemetry.io/proto/otlp v1.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.14.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.38.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.14.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sys v0.33.0 // indirect golang.org/x/sync v0.16.0 // indirect
golang.org/x/text v0.24.0 // indirect golang.org/x/sys v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc 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/grpc v1.71.0 // indirect
google.golang.org/protobuf v1.36.5 // indirect google.golang.org/protobuf v1.36.7 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.65.6 // indirect modernc.org/libc v1.66.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.10.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.37.0 // indirect modernc.org/sqlite v1.38.2 // indirect
) )

View File

@@ -8,30 +8,28 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8= github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
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 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= 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/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.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8= github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM= github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs= github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI= github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
@@ -54,27 +52,27 @@ github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGV
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k= github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk= github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo= github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig= github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 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.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -83,27 +81,35 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8= github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus= github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8= github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us= github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8= github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk= github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 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-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-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
@@ -127,8 +133,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI= github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ= github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8= github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -157,10 +163,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/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 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= 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.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
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 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -169,18 +173,22 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY= github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE= github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY= github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo= github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
@@ -208,10 +216,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek= github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M= github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M= github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc= github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -231,6 +241,8 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 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/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -240,7 +252,6 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
@@ -248,18 +259,21 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc= go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI= 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 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
@@ -268,8 +282,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ= go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y= go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 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/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 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
@@ -292,47 +306,49 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= go.opentelemetry.io/otel/exporters/stdout/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 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= 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.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
go.opentelemetry.io/otel/log v0.10.0/go.mod h1:PbVdm9bXKku/gL0oFfUF4wwsQsOPlpo4VEqjvxih+FM= go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M= go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE= go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= 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 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo= 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 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= 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.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE= golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ= golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
@@ -344,8 +360,11 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -353,8 +372,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -367,8 +386,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -379,6 +398,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -387,27 +407,29 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= 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 h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ= 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 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= 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.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -415,32 +437,33 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8= gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ= gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s= modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= 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.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= 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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= 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 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 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

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

View File

@@ -3,7 +3,7 @@ package bootstrap
import ( import (
"context" "context"
"fmt" "fmt"
"log" "log/slog"
"time" "time"
_ "github.com/golang-migrate/migrate/v4/source/file" _ "github.com/golang-migrate/migrate/v4/source/file"
@@ -14,16 +14,23 @@ import (
) )
func Bootstrap(ctx context.Context) error { func Bootstrap(ctx context.Context) error {
initApplicationImages() // Initialize the observability stack, including the logger, distributed tracing, and metrics
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
// Initialize the tracer and metrics exporter
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err) return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
} }
slog.InfoContext(ctx, "Pocket ID is starting")
err = initApplicationImages()
if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Connect to the database // Connect to the database
db := NewDatabase() db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Create all services // Create all services
svc, err := initServices(ctx, db, httpClient) svc, err := initServices(ctx, db, httpClient)
@@ -62,7 +69,7 @@ func Bootstrap(ctx context.Context) error {
NewServiceRunner(shutdownFns...). NewServiceRunner(shutdownFns...).
Run(shutdownCtx) //nolint:contextcheck Run(shutdownCtx) //nolint:contextcheck
if err != nil { if err != nil {
log.Printf("Error shutting down services: %v", err) slog.Error("Error shutting down services", slog.Any("error", err))
} }
return nil return nil

View File

@@ -3,9 +3,10 @@ package bootstrap
import ( import (
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"net/url" "net/url"
"os" "os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -14,23 +15,27 @@ import (
"github.com/golang-migrate/migrate/v4/database" "github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/github"
"github.com/golang-migrate/migrate/v4/source/iofs" "github.com/golang-migrate/migrate/v4/source/iofs"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" gormLogger "gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
func NewDatabase() (db *gorm.DB) { func NewDatabase() (db *gorm.DB, err error) {
db, err := connectDatabase() db, err = connectDatabase()
if err != nil { if err != nil {
log.Fatalf("failed to connect to database: %v", err) return nil, fmt.Errorf("failed to connect to database: %w", err)
} }
sqlDb, err := db.DB() sqlDb, err := db.DB()
if err != nil { if err != nil {
log.Fatalf("failed to get sql.DB: %v", err) return nil, fmt.Errorf("failed to get sql.DB: %w", err)
} }
// Choose the correct driver for the database provider // Choose the correct driver for the database provider
@@ -42,23 +47,24 @@ func NewDatabase() (db *gorm.DB) {
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default: default:
// Should never happen at this point // Should never happen at this point
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider) return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
} }
if err != nil { if err != nil {
log.Fatalf("failed to create migration driver: %v", err) return nil, fmt.Errorf("failed to create migration driver: %w", err)
} }
// Run migrations // Run migrations
if err := migrateDatabase(driver); err != nil { if err := migrateDatabase(driver); err != nil {
log.Fatalf("failed to run migrations: %v", err) return nil, fmt.Errorf("failed to run migrations: %w", err)
} }
return db return db, nil
} }
func migrateDatabase(driver database.Driver) error { func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations // Embedded migrations via iofs
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider)) path := "migrations/" + string(common.EnvConfig.DbProvider)
source, err := iofs.New(resources.FS, path)
if err != nil { if err != nil {
return fmt.Errorf("failed to create embedded migration source: %w", err) return fmt.Errorf("failed to create embedded migration source: %w", err)
} }
@@ -68,14 +74,66 @@ func migrateDatabase(driver database.Driver) error {
return fmt.Errorf("failed to create migration instance: %w", err) return fmt.Errorf("failed to create migration instance: %w", err)
} }
err = m.Up() requiredVersion, err := getRequiredMigrationVersion(path)
if err != nil && !errors.Is(err, migrate.ErrNoChange) { if err != nil {
return fmt.Errorf("failed to apply migrations: %w", err) return fmt.Errorf("failed to get last migration version: %w", err)
} }
currentVersion, _, _ := m.Version()
if currentVersion > requiredVersion {
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
if !common.EnvConfig.AllowDowngrade {
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
}
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
return migrateDatabaseFromGitHub(driver, requiredVersion)
}
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply embedded migrations: %w", err)
}
return nil return nil
} }
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
}
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
}
return nil
}
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
func getRequiredMigrationVersion(path string) (uint, error) {
entries, err := resources.FS.ReadDir(path)
if err != nil {
return 0, fmt.Errorf("failed to read migration directory: %w", err)
}
var maxVersion uint
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var version uint
n, err := fmt.Sscanf(name, "%d_", &version)
if err == nil && n == 1 {
if version > maxVersion {
maxVersion = version
}
}
}
return maxVersion, nil
}
func connectDatabase() (db *gorm.DB, err error) { func connectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector var dialector gorm.Dialector
@@ -85,13 +143,20 @@ func connectDatabase() (db *gorm.DB, err error) {
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database") return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
} }
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'") sqliteutil.RegisterSqliteFunctions()
}
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString) connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
err = ensureSqliteTempDir(filepath.Dir(dbPath))
if err != nil {
return nil, err
}
dialector = sqlite.Open(connString) dialector = sqlite.Open(connString)
case common.DbProviderPostgres: case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
@@ -105,38 +170,68 @@ func connectDatabase() (db *gorm.DB, err error) {
for i := 1; i <= 3; i++ { for i := 1; i <= 3; i++ {
db, err = gorm.Open(dialector, &gorm.Config{ db, err = gorm.Open(dialector, &gorm.Config{
TranslateError: true, TranslateError: true,
Logger: getLogger(), Logger: getGormLogger(),
}) })
if err == nil { if err == nil {
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
return db, nil return db, nil
} }
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i) slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second) time.Sleep(3 * time.Second)
} }
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
return nil, err return nil, err
} }
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString
}
// Check if we're using an in-memory database
isMemoryDB := isSqliteInMemory(connString)
// Parse the connection string
connStringUrl, err := url.Parse(connString)
if err != nil {
return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
}
// Convert options for the old SQLite driver to the new one
convertSqlitePragmaArgs(connStringUrl)
// Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil {
return "", "", fmt.Errorf("invalid SQLite connection string: %w", err)
}
// Get the absolute path to the database
// Here, we know for a fact that the ? is present
parsedConnString = connStringUrl.String()
idx := strings.IndexRune(parsedConnString, '?')
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
if err != nil {
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
}
return parsedConnString, dbPath, nil
}
// The official C implementation of SQLite allows some additional properties in the connection string // 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. // 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 // 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. // previously (via github.com/mattn/go-sqlite3), we are converting some options.
func parseSqliteConnectionString(connString string) (string, error) { // Note this function updates connStringUrl.
if !strings.HasPrefix(connString, "file:") { func convertSqlitePragmaArgs(connStringUrl *url.URL) {
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 // 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 // This only includes a subset of options, excluding those that are not relevant to us
qs := make(url.Values, len(connStringUrl.Query())) qs := make(url.Values, len(connStringUrl.Query()))
for k, v := range connStringUrl.Query() { for k, v := range connStringUrl.Query() {
switch k { switch strings.ToLower(k) {
case "_auto_vacuum", "_vacuum": case "_auto_vacuum", "_vacuum":
qs.Add("_pragma", "auto_vacuum("+v[0]+")") qs.Add("_pragma", "auto_vacuum("+v[0]+")")
case "_busy_timeout", "_timeout": case "_busy_timeout", "_timeout":
@@ -157,29 +252,186 @@ func parseSqliteConnectionString(connString string) (string, error) {
} }
} }
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode() connStringUrl.RawQuery = qs.Encode()
return connStringUrl.String(), nil
} }
func getLogger() logger.Interface { // Adds the default (and some required) parameters to the SQLite connection string.
isProduction := common.EnvConfig.AppEnv == "production" // Note this function updates connStringUrl.
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
// Copyright (C) 2023 The Dapr Authors
// License: Apache2
const defaultBusyTimeout = 2500 * time.Millisecond
var logLevel logger.LogLevel // Get the "query string" from the connection string if present
if isProduction { qs := connStringUrl.Query()
logLevel = logger.Error if len(qs) == 0 {
} else { qs = make(url.Values, 2)
logLevel = logger.Info
} }
return logger.New( // If the database is in-memory, we must ensure that cache=shared is set
log.New(os.Stdout, "\r\n", log.LstdFlags), if isMemoryDB {
logger.Config{ qs["cache"] = []string{"shared"}
SlowThreshold: 200 * time.Millisecond, }
LogLevel: logLevel,
IgnoreRecordNotFoundError: isProduction, // Check if the database is read-only or immutable
ParameterizedQueries: isProduction, isReadOnly := false
Colorful: !isProduction, if len(qs["mode"]) > 0 {
}, // Keep the first value only
) qs["mode"] = []string{
strings.ToLower(qs["mode"][0]),
}
if qs["mode"][0] == "ro" {
isReadOnly = true
}
}
if len(qs["immutable"]) > 0 {
// Keep the first value only
qs["immutable"] = []string{
strings.ToLower(qs["immutable"][0]),
}
if qs["immutable"][0] == "1" {
isReadOnly = true
}
}
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
if len(qs["_txlock"]) > 0 {
// Keep the first value only
qs["_txlock"] = []string{
strings.ToLower(qs["_txlock"][0]),
}
if qs["_txlock"][0] != "immediate" {
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
}
} else {
qs["_txlock"] = []string{"immediate"}
}
// Add pragma values
var hasBusyTimeout, hasJournalMode bool
if len(qs["_pragma"]) == 0 {
qs["_pragma"] = make([]string, 0, 3)
} else {
for _, p := range qs["_pragma"] {
p = strings.ToLower(p)
switch {
case strings.HasPrefix(p, "busy_timeout"):
hasBusyTimeout = true
case strings.HasPrefix(p, "journal_mode"):
hasJournalMode = true
case strings.HasPrefix(p, "foreign_keys"):
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
}
}
}
if !hasBusyTimeout {
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
}
if !hasJournalMode {
switch {
case isMemoryDB:
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
case isReadOnly:
// Set the journaling mode to "DELETE" (the default) if the database is read-only
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
default:
// Enable WAL
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
}
}
// Forcefully enable foreign keys
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
// Update the connStringUrl object
connStringUrl.RawQuery = qs.Encode()
return nil
}
// isSqliteInMemory returns true if the connection string is for an in-memory database.
func isSqliteInMemory(connString string) bool {
lc := strings.ToLower(connString)
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
return true
}
// Another way is to pass "mode=memory" in the "query string"
idx := strings.IndexRune(lc, '?')
if idx < 0 {
return false
}
qs, _ := url.ParseQuery(lc[(idx + 1):])
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
}
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
// The default directory may not be writable when using a container with a read-only root file system
// See: https://www.sqlite.org/tempfiles.html
func ensureSqliteTempDir(dbPath string) error {
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
//
// - The SQLITE_TMPDIR environment variable
// - The TMPDIR environment variable
// - /var/tmp
// - /usr/tmp
// - /tmp
//
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
//
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
return nil
}
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
ok, err := utils.IsWritableDir(dir)
if err != nil {
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
}
if ok {
// We found a folder that's writable
return nil
}
}
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
err := os.Setenv("SQLITE_TMPDIR", dbPath)
if err != nil {
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
}
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
return nil
}
func getGormLogger() gormLogger.Interface {
loggerOpts := make([]slogGorm.Option, 0, 5)
loggerOpts = append(loggerOpts,
slogGorm.WithSlowThreshold(200*time.Millisecond),
slogGorm.WithErrorField("error"),
)
if common.EnvConfig.AppEnv == "production" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
} else {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(),
)
}
return slogGorm.New(loggerOpts...)
} }

View File

@@ -8,23 +8,93 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestParseSqliteConnectionString(t *testing.T) { func TestIsSqliteInMemory(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
input string connStr string
expected string expected bool
expectedError bool }{
{
name: "memory database with :memory:",
connStr: ":memory:",
expected: true,
},
{
name: "memory database with file::memory:",
connStr: "file::memory:",
expected: true,
},
{
name: "memory database with :MEMORY: (uppercase)",
connStr: ":MEMORY:",
expected: true,
},
{
name: "memory database with FILE::MEMORY: (uppercase)",
connStr: "FILE::MEMORY:",
expected: true,
},
{
name: "memory database with mixed case",
connStr: ":Memory:",
expected: true,
},
{
name: "has mode=memory",
connStr: "file:data?mode=memory",
expected: true,
},
{
name: "file database",
connStr: "data.db",
expected: false,
},
{
name: "file database with path",
connStr: "/path/to/data.db",
expected: false,
},
{
name: "file database with file: prefix",
connStr: "file:data.db",
expected: false,
},
{
name: "empty string",
connStr: "",
expected: false,
},
{
name: "string containing memory but not at start",
connStr: "data:memory:.db",
expected: false,
},
{
name: "has mode=ro",
connStr: "file:data?mode=ro",
expected: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isSqliteInMemory(tt.connStr)
assert.Equal(t, tt.expected, result)
})
}
}
func TestConvertSqlitePragmaArgs(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{ }{
{ {
name: "basic file path", name: "basic file path",
input: "file:test.db", input: "file:test.db",
expected: "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", name: "converts _busy_timeout to pragma",
input: "file:test.db?_busy_timeout=5000", input: "file:test.db?_busy_timeout=5000",
@@ -100,46 +170,161 @@ func TestParseSqliteConnectionString(t *testing.T) {
input: "file:test.db?_fk=1&mode=rw&_timeout=5000", 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", expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
}, },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resultURL, _ := url.Parse(tt.input)
convertSqlitePragmaArgs(resultURL)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err)
// Compare scheme and path components
compareQueryStrings(t, expectedURL, resultURL)
})
}
}
func TestAddSqliteDefaultParameters(t *testing.T) {
tests := []struct {
name string
input string
isMemoryDB bool
expected string
expectError bool
}{
{ {
name: "invalid URL format", name: "basic file database",
input: "file:invalid#$%^&*@test.db", input: "file:test.db",
expectedError: true, isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "in-memory database",
input: "file::memory:",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
},
{
name: "read-only database with mode=ro",
input: "file:test.db?mode=ro",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "immutable database",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "database with existing _txlock",
input: "file:test.db?_txlock=deferred",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
},
{
name: "database with existing busy_timeout pragma",
input: "file:test.db?_pragma=busy_timeout%285000%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
},
{
name: "database with existing journal_mode pragma",
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
},
{
name: "database with forbidden foreign_keys pragma",
input: "file:test.db?_pragma=foreign_keys%280%29",
isMemoryDB: false,
expectError: true,
},
{
name: "database with multiple existing pragmas",
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
},
{
name: "in-memory database with cache already set",
input: "file::memory:?cache=private",
isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
},
{
name: "database with mode=rw (not read-only)",
input: "file:test.db?mode=rw",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
},
{
name: "database with immutable=0 (not immutable)",
input: "file:test.db?immutable=0",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
},
{
name: "database with mixed case mode=RO",
input: "file:test.db?mode=RO",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
},
{
name: "database with mixed case immutable=1",
input: "file:test.db?immutable=1",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
},
{
name: "complex database configuration",
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
isMemoryDB: false,
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result, err := parseSqliteConnectionString(tt.input) resultURL, err := url.Parse(tt.input)
require.NoError(t, err)
if tt.expectedError { err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
if tt.expectError {
require.Error(t, err) require.Error(t, err)
return return
} }
require.NoError(t, err) require.NoError(t, err)
// Parse both URLs to compare components independently
expectedURL, err := url.Parse(tt.expected) expectedURL, err := url.Parse(tt.expected)
require.NoError(t, err) require.NoError(t, err)
resultURL, err := url.Parse(result) compareQueryStrings(t, expectedURL, resultURL)
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)
}
}) })
} }
} }
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
t.Helper()
// Compare scheme and path components
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
assert.Equal(t, expectedURL.Path, resultURL.Path)
// Compare query parameters regardless of order
expectedQuery := expectedURL.Query()
resultQuery := resultURL.Query()
assert.Len(t, expectedQuery, len(resultQuery))
for key, expectedValues := range expectedQuery {
resultValues, ok := resultQuery[key]
_ = assert.True(t, ok) &&
assert.ElementsMatch(t, expectedValues, resultValues)
}
}

View File

@@ -3,7 +3,8 @@
package bootstrap package bootstrap
import ( import (
"log" "log/slog"
"os"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"gorm.io/gorm" "gorm.io/gorm"
@@ -18,7 +19,8 @@ func init() {
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService) testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
if err != nil { if err != nil {
log.Fatalf("failed to initialize test service: %v", err) slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1)
return return
} }

View File

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

View File

@@ -1,107 +0,0 @@
package bootstrap
import (
"context"
"fmt"
"net/http"
"time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"go.opentelemetry.io/otel"
metricnoop "go.opentelemetry.io/otel/metric/noop"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
tracenoop "go.opentelemetry.io/otel/trace/noop"
)
func defaultResource() (*resource.Resource, error) {
return resource.Merge(
resource.Default(),
resource.NewSchemaless(
semconv.ServiceName("pocket-id-backend"),
semconv.ServiceVersion(common.Version),
),
)
}
func initOtel(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
resource, err := defaultResource()
if err != nil {
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
}
shutdownFns = make([]utils.Service, 0, 2)
httpClient = &http.Client{}
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
if !ok {
// Indicates a development-time error
panic("Default transport is not of type *http.Transport")
}
httpClient.Transport = defaultTransport.Clone()
if traces {
tr, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithResource(resource),
sdktrace.WithBatcher(tr),
)
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(
propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
),
)
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer tpCancel()
shutdownErr := tp.Shutdown(tpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
}
return nil
})
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
} else {
otel.SetTracerProvider(tracenoop.NewTracerProvider())
}
if metrics {
mr, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
}
mp := metric.NewMeterProvider(
metric.WithResource(resource),
metric.WithReader(mr),
)
otel.SetMeterProvider(mp)
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
defer mpCancel()
shutdownErr := mp.Shutdown(mpCtx)
if shutdownErr != nil {
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
}
return nil
})
} else {
otel.SetMeterProvider(metricnoop.NewMeterProvider())
}
return shutdownFns, httpClient, nil
}

View File

@@ -4,7 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"net" "net"
"net/http" "net/http"
"os" "os"
@@ -12,13 +12,13 @@ import (
"strings" "strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
"golang.org/x/time/rate" "golang.org/x/time/rate"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/frontend"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/controller" "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/middleware"
@@ -32,7 +32,8 @@ var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *
func initRouter(db *gorm.DB, svc *services) utils.Service { func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc) runner, err := initRouterInternal(db, svc)
if err != nil { if err != nil {
log.Fatalf("failed to init router: %v", err) slog.Error("Failed to init router", "error", err)
os.Exit(1)
} }
return runner return runner
} }
@@ -60,21 +61,25 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
} }
r := gin.New() r := gin.New()
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: func(c *gin.Context) bool { r.Use(sloggin.NewWithConfig(slog.Default(), sloggin.Config{
for _, prefix := range loggerSkipPathsPrefix { Filters: []sloggin.Filter{
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) { func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return false
}
}
return true return true
} },
} },
return false }))
}}))
if !common.EnvConfig.TrustProxy { if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil) _ = r.SetTrustedProxies(nil)
} }
if common.EnvConfig.TracingEnabled { if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware("pocket-id-backend")) r.Use(otelgin.Middleware(common.Name))
} }
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60) rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
@@ -85,7 +90,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
err := frontend.RegisterFrontend(r) err := frontend.RegisterFrontend(r)
if errors.Is(err, frontend.ErrFrontendNotIncluded) { if errors.Is(err, frontend.ErrFrontendNotIncluded) {
log.Println("Frontend is not included in the build. Skipping frontend registration.") slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil { } else if err != nil {
return nil, fmt.Errorf("failed to register frontend: %w", err) return nil, fmt.Errorf("failed to register frontend: %w", err)
} }
@@ -135,7 +140,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
addr = common.EnvConfig.UnixSocket addr = common.EnvConfig.UnixSocket
} }
listener, err := net.Listen(network, addr) listener, err := net.Listen(network, addr) //nolint:noctx
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create %s listener: %w", network, err) return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
} }
@@ -154,7 +159,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Service runner function // Service runner function
runFn := func(ctx context.Context) error { runFn := func(ctx context.Context) error {
log.Printf("Server listening on %s", addr) slog.Info("Server listening", slog.String("addr", addr))
// Start the server in a background goroutine // Start the server in a background goroutine
go func() { go func() {
@@ -163,7 +168,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Next call blocks until the server is shut down // Next call blocks until the server is shut down
srvErr := srv.Serve(listener) srvErr := srv.Serve(listener)
if srvErr != http.ErrServerClosed { if srvErr != http.ErrServerClosed {
log.Fatalf("Error starting app server: %v", srvErr) slog.Error("Error starting app server", "error", srvErr)
os.Exit(1)
} }
}() }()
@@ -171,7 +177,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
err = systemd.SdNotifyReady() err = systemd.SdNotifyReady()
if err != nil { if err != nil {
// Log the error only // Log the error only
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err) slog.Warn("Unable to notify systemd that the service is ready", "error", err)
} }
// Block until the context is canceled // Block until the context is canceled
@@ -184,7 +190,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
shutdownCancel() shutdownCancel()
if shutdownErr != nil { if shutdownErr != nil {
// Log the error only (could be context canceled) // Log the error only (could be context canceled)
log.Printf("[WARN] App server shutdown error: %v", shutdownErr) slog.Warn("App server shutdown error", "error", shutdownErr)
} }
return nil return nil

View File

@@ -29,7 +29,10 @@ type services struct {
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) { func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
svc = &services{} svc = &services{}
svc.appConfigService = service.NewAppConfigService(ctx, db) svc.appConfigService, err = service.NewAppConfigService(ctx, db)
if err != nil {
return nil, fmt.Errorf("failed to create app config service: %w", err)
}
svc.emailService, err = service.NewEmailService(db, svc.appConfigService) svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil { if err != nil {
@@ -38,19 +41,26 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.geoLiteService = service.NewGeoLiteService(httpClient) svc.geoLiteService = service.NewGeoLiteService(httpClient)
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService) svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
svc.jwtService = service.NewJwtService(db, svc.appConfigService) svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService) if err != nil {
svc.customClaimService = service.NewCustomClaimService(db) return nil, fmt.Errorf("failed to create JWT service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService) svc.customClaimService = service.NewCustomClaimService(db)
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
if err != nil {
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
}
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create OIDC service: %w", err) return nil, fmt.Errorf("failed to create OIDC service: %w", err)
} }
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService) svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
return svc, nil return svc, nil
} }

View File

@@ -30,7 +30,10 @@ func init() {
Use: "key-rotate", Use: "key-rotate",
Short: "Generates a new token signing key and replaces the current one", Short: "Generates a new token signing key and replaces the current one",
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
db := bootstrap.NewDatabase() db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig) return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
}, },
@@ -80,7 +83,10 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
} }
// Init the services we need // Init the services we need
appConfigService := service.NewAppConfigService(ctx, db) appConfigService, err := service.NewAppConfigService(ctx, db)
if err != nil {
return fmt.Errorf("failed to create app config service: %w", err)
}
// Get the key provider // Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value) keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)

View File

@@ -97,7 +97,8 @@ func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bo
db := testingutils.NewDatabaseForTest(t) db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance // Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db) appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Check if key exists before rotation // Check if key exists before rotation
@@ -140,14 +141,15 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
// Set up database storage config // Set up database storage config
envConfig := &common.EnvConfigSchema{ envConfig := &common.EnvConfigSchema{
KeysStorage: "database", KeysStorage: "database",
EncryptionKey: "test-encryption-key-characters-long", EncryptionKey: []byte("test-encryption-key-characters-long"),
} }
// Create test database // Create test database
db := testingutils.NewDatabaseForTest(t) db := testingutils.NewDatabaseForTest(t)
// Initialize app config service and create instance // Initialize app config service and create instance
appConfigService := service.NewAppConfigService(t.Context(), db) appConfigService, err := service.NewAppConfigService(t.Context(), db)
require.NoError(t, err)
instanceID := appConfigService.GetDbConfig().InstanceID.Value instanceID := appConfigService.GetDbConfig().InstanceID.Value
// Get key provider // Get key provider

View File

@@ -24,11 +24,14 @@ var oneTimeAccessTokenCmd = &cobra.Command{
userArg := args[0] userArg := args[0]
// Connect to the database // Connect to the database
db := bootstrap.NewDatabase() db, err := bootstrap.NewDatabase()
if err != nil {
return err
}
// Create the access token // Create the access token
var oneTimeAccessToken *model.OneTimeAccessToken var oneTimeAccessToken *model.OneTimeAccessToken
err := db.Transaction(func(tx *gorm.DB) error { err = db.Transaction(func(tx *gorm.DB) error {
// Load the user to retrieve the user ID // Load the user to retrieve the user ID
var user model.User var user model.User
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second) queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
@@ -48,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
} }
// Create a new access token that expires in 1 hour // Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour)) oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
if txErr != nil { if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr) return fmt.Errorf("failed to generate access token: %w", txErr)
} }

View File

@@ -3,8 +3,11 @@ package common
import ( import (
"errors" "errors"
"fmt" "fmt"
"log" "log/slog"
"net/url" "net/url"
"os"
"reflect"
"strings"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
@@ -23,32 +26,33 @@ const (
DbProviderSqlite DbProvider = "sqlite" DbProviderSqlite DbProvider = "sqlite"
DbProviderPostgres DbProvider = "postgres" DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz" MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate" defaultSqliteConnString string = "data/pocket-id.db"
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV"`
AppURL string `env:"APP_URL"` AppURL string `env:"APP_URL"`
DbProvider DbProvider `env:"DB_PROVIDER"` DbProvider DbProvider `env:"DB_PROVIDER"`
DbConnectionString string `env:"DB_CONNECTION_STRING"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"` KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey string `env:"ENCRYPTION_KEY"` EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"`
Port string `env:"PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST"`
UnixSocket string `env:"UNIX_SOCKET"` UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"` UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"` LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
MetricsEnabled bool `env:"METRICS_ENABLED"` MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"`
LogJSON bool `env:"LOG_JSON"`
TrustProxy bool `env:"TRUST_PROXY"` TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
} }
var EnvConfig = defaultConfig() var EnvConfig = defaultConfig()
@@ -56,7 +60,8 @@ var EnvConfig = defaultConfig()
func init() { func init() {
err := parseEnvConfig() err := parseEnvConfig()
if err != nil { if err != nil {
log.Fatalf("Configuration error: %v", err) slog.Error("Configuration error", slog.Any("error", err))
os.Exit(1)
} }
} }
@@ -68,7 +73,7 @@ func defaultConfig() EnvConfigSchema {
UploadPath: "data/uploads", UploadPath: "data/uploads",
KeysPath: "data/keys", KeysPath: "data/keys",
KeysStorage: "", // "database" or "file" KeysStorage: "", // "database" or "file"
EncryptionKey: "", EncryptionKey: nil,
AppURL: "http://localhost:1411", AppURL: "http://localhost:1411",
Port: "1411", Port: "1411",
Host: "0.0.0.0", Host: "0.0.0.0",
@@ -83,15 +88,29 @@ func defaultConfig() EnvConfigSchema {
TracingEnabled: false, TracingEnabled: false,
TrustProxy: false, TrustProxy: false,
AnalyticsDisabled: false, AnalyticsDisabled: false,
AllowDowngrade: false,
} }
} }
func parseEnvConfig() error { func parseEnvConfig() error {
err := env.ParseWithOptions(&EnvConfig, env.Options{}) parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
return []byte(value), nil
},
}
err := env.ParseWithOptions(&EnvConfig, env.Options{
FuncMap: parsers,
})
if err != nil { if err != nil {
return fmt.Errorf("error parsing env config: %w", err) return fmt.Errorf("error parsing env config: %w", err)
} }
err = resolveFileBasedEnvVariables(&EnvConfig)
if err != nil {
return err
}
// Validate the environment variables // Validate the environment variables
switch EnvConfig.DbProvider { switch EnvConfig.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
@@ -119,9 +138,8 @@ func parseEnvConfig() error {
case "": case "":
EnvConfig.KeysStorage = "file" EnvConfig.KeysStorage = "file"
case "database": case "database":
// If KeysStorage is "database", a key must be specified if EnvConfig.EncryptionKey == nil {
if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" { return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database")
} }
case "file": case "file":
// All good, these are valid values // All good, these are valid values
@@ -131,3 +149,58 @@ func parseEnvConfig() error {
return nil return nil
} }
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem()
typ := val.Type()
for i := 0; i < val.NumField(); i++ {
field := val.Field(i)
fieldType := typ.Field(i)
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
continue
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" {
continue
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
continue
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
continue
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
}
return nil
}

View File

@@ -1,6 +1,7 @@
package common package common
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -110,7 +111,7 @@ func TestParseEnvConfig(t *testing.T) {
err := parseEnvConfig() err := parseEnvConfig()
require.Error(t, err) require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty") assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
}) })
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) { t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
@@ -186,3 +187,119 @@ func TestParseEnvConfig(t *testing.T) {
assert.Equal(t, "127.0.0.1", EnvConfig.Host) assert.Equal(t, "127.0.0.1", EnvConfig.Host)
}) })
} }
func TestResolveFileBasedEnvVariables(t *testing.T) {
// Create temporary directory for test files
tempDir := t.TempDir()
// Create test files
encryptionKeyFile := tempDir + "/encryption_key.txt"
encryptionKeyContent := "test-encryption-key-123"
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
require.NoError(t, err)
dbConnFile := tempDir + "/db_connection.txt"
dbConnContent := "postgres://user:pass@localhost/testdb"
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) {
config := defaultConfig()
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// Verify file contents were read correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
})
}

View File

@@ -350,6 +350,15 @@ func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
type ReauthenticationRequiredError struct{}
func (e *ReauthenticationRequiredError) Error() string {
return "reauthentication required"
}
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
return http.StatusUnauthorized
}
type OpenSignupDisabledError struct{} type OpenSignupDisabledError struct{}
func (e *OpenSignupDisabledError) Error() string { func (e *OpenSignupDisabledError) Error() string {
@@ -359,3 +368,13 @@ func (e *OpenSignupDisabledError) Error() string {
func (e *OpenSignupDisabledError) HttpStatusCode() int { func (e *OpenSignupDisabledError) HttpStatusCode() int {
return http.StatusForbidden return http.StatusForbidden
} }
type ClientIdAlreadyExistsError struct{}
func (e *ClientIdAlreadyExistsError) Error() string {
return "Client ID already in use"
}
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
return http.StatusBadRequest
}

View File

@@ -1,5 +1,8 @@
package common package common
// Name is the name of the application
const Name = "pocket-id"
// Version contains the Pocket ID version. // Version contains the Pocket ID version.
// //
// It can be set at build time using -ldflags. // It can be set at build time using -ldflags.

View File

@@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
userID := ctx.GetString("userID") userID := ctx.GetString("userID")
var input dto.ApiKeyCreateDto var input dto.ApiKeyCreateDto
if err := ctx.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
_ = ctx.Error(err) _ = ctx.Error(err)
return return
} }

View File

@@ -109,7 +109,7 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
// @Router /api/application-configuration [put] // @Router /api/application-configuration [put]
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) { func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
var input dto.AppConfigUpdateDto var input dto.AppConfigUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -59,7 +59,7 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) { func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -93,7 +93,7 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) { func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
var input []dto.CustomClaimCreateDto var input []dto.CustomClaimCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -40,7 +40,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return return
} }
if err := tc.TestService.ResetApplicationImages(); err != nil { if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -2,7 +2,7 @@ package controller
import ( import (
"errors" "errors"
"log" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
@@ -55,8 +55,13 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler) group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler) 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/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler) group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
} }
type OidcController struct { type OidcController struct {
@@ -257,7 +262,7 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID")) callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
if err != nil { if err != nil {
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected // If the validation fails, the user has to confirm the logout manually and doesn't get redirected
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err) slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout") c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
return return
} }
@@ -487,11 +492,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
// @Accept json // @Accept json
// @Produce json // @Produce json
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Param client body dto.OidcClientCreateDto true "Client information" // @Param client body dto.OidcClientUpdateDto true "Client information"
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client" // @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
// @Router /api/oidc/clients/{id} [put] // @Router /api/oidc/clients/{id} [put]
func (oc *OidcController) updateClientHandler(c *gin.Context) { func (oc *OidcController) updateClientHandler(c *gin.Context) {
var input dto.OidcClientCreateDto var input dto.OidcClientUpdateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -657,7 +662,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
// @Param sort[column] query string false "Column to sort by" // @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto] // @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/me/clients [get] // @Router /api/oidc/users/me/authorized-clients [get]
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) { func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
oc.listAuthorizedClients(c, userID) oc.listAuthorizedClients(c, userID)
@@ -673,7 +678,7 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
// @Param sort[column] query string false "Column to sort by" // @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc") // @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto] // @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
// @Router /api/oidc/users/{id}/clients [get] // @Router /api/oidc/users/{id}/authorized-clients [get]
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) { func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
oc.listAuthorizedClients(c, userID) oc.listAuthorizedClients(c, userID)
@@ -704,6 +709,58 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
}) })
} }
// revokeOwnClientAuthorizationHandler godoc
// @Summary Revoke authorization for an OIDC client
// @Description Revoke the authorization for a specific OIDC client for the current user
// @Tags OIDC
// @Param clientId path string true "Client ID to revoke authorization for"
// @Success 204 "No Content"
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
clientID := c.Param("clientId")
userID := c.GetString("userID")
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
if err != nil {
_ = c.Error(err)
return
}
c.Status(http.StatusNoContent)
}
// listOwnAccessibleClientsHandler godoc
// @Summary List accessible OIDC clients for current user
// @Description Get a list of OIDC clients that the current user can access
// @Tags OIDC
// @Param pagination[page] query int false "Page number for pagination" default(1)
// @Param pagination[limit] query int false "Number of items per page" default(20)
// @Param sort[column] query string false "Column to sort by"
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
userID := c.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
Data: clients,
Pagination: pagination,
})
}
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) { func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
userCode := c.Query("code") userCode := c.Query("code")
if userCode == "" { if userCode == "" {

View File

@@ -14,6 +14,11 @@ import (
"golang.org/x/time/rate" "golang.org/x/time/rate"
) )
const (
defaultOneTimeAccessTokenDuration = 15 * time.Minute
defaultSignupTokenDuration = time.Hour
)
// NewUserController creates a new controller for user management endpoints // NewUserController creates a new controller for user management endpoints
// @Summary User management controller // @Summary User management controller
// @Description Initializes all user-related API endpoints // @Description Initializes all user-related API endpoints
@@ -193,7 +198,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
// @Router /api/users [post] // @Router /api/users [post]
func (uc *UserController) createUserHandler(c *gin.Context) { func (uc *UserController) createUserHandler(c *gin.Context) {
var input dto.UserCreateDto var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -331,10 +336,17 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
return return
} }
var ttl time.Duration
if own { if own {
input.UserID = c.GetString("userID") input.UserID = c.GetString("userID")
ttl = defaultOneTimeAccessTokenDuration
} else {
ttl = input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
} }
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt) token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -378,7 +390,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
// @Router /api/one-time-access-email [post] // @Router /api/one-time-access-email [post]
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) { func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -411,7 +423,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
userID := c.Param("id") userID := c.Param("id")
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt) ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration
}
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -457,7 +473,7 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
// @Router /api/signup/setup [post] // @Router /api/signup/setup [post]
func (uc *UserController) signUpInitialAdmin(c *gin.Context) { func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
var input dto.SignUpDto var input dto.SignUpDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -526,14 +542,20 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
return return
} }
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit) ttl := input.TTL.Duration
if ttl <= 0 {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
var tokenDto dto.SignupTokenDto var tokenDto dto.SignupTokenDto
if err := dto.MapStruct(signupToken, &tokenDto); err != nil { err = dto.MapStruct(signupToken, &tokenDto)
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -606,7 +628,7 @@ func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
// @Router /api/signup [post] // @Router /api/signup [post]
func (uc *UserController) signupHandler(c *gin.Context) { func (uc *UserController) signupHandler(c *gin.Context) {
var input dto.SignUpDto var input dto.SignUpDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -635,7 +657,7 @@ func (uc *UserController) signupHandler(c *gin.Context) {
// updateUser is an internal helper method, not exposed as an API endpoint // updateUser is an internal helper method, not exposed as an API endpoint
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) { func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
var input dto.UserCreateDto var input dto.UserCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -120,7 +120,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Router /api/user-groups [post] // @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) { func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -152,7 +152,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Router /api/user-groups/{id} [put] // @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) { func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto var input dto.UserGroupCreateDto
if err := c.ShouldBindJSON(&input); err != nil { if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }

View File

@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler) group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler) group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler) group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler) group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
cookie.AddAccessTokenCookie(c, 0, "") cookie.AddAccessTokenCookie(c, 0, "")
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
if err != nil {
_ = c.Error(&common.MissingSessionIdError{})
return
}
var token string
// Try to create a reauthentication token with WebAuthn
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
if err == nil {
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
if err != nil {
_ = c.Error(err)
return
}
} else {
// If WebAuthn fails, try to create a reauthentication token with the access token
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
if err != nil {
_ = c.Error(err)
return
}
}
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
}

View File

@@ -3,8 +3,9 @@ package controller
import ( import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log/slog"
"net/http" "net/http"
"os"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
var err error var err error
wkc.oidcConfig, err = wkc.computeOIDCConfiguration() wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
if err != nil { if err != nil {
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err) slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
os.Exit(1)
return
} }
group.GET("/.well-known/jwks.json", wkc.jwksHandler) group.GET("/.well-known/jwks.json", wkc.jwksHandler)
@@ -84,6 +87,7 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"subject_types_supported": []string{"public"}, "subject_types_supported": []string{"public"},
"id_token_signing_alg_values_supported": []string{alg.String()}, "id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true, "authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"},
} }
return json.Marshal(config) return json.Marshal(config)
} }

View File

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

View File

@@ -12,12 +12,14 @@ type AppConfigVariableDto struct {
} }
type AppConfigUpdateDto struct { type AppConfigUpdateDto struct {
AppName string `json:"appName" binding:"required,min=1,max=30"` AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
SessionDuration string `json:"sessionDuration" binding:"required"` SessionDuration string `json:"sessionDuration" binding:"required"`
EmailsVerified string `json:"emailsVerified" binding:"required"` EmailsVerified string `json:"emailsVerified" binding:"required"`
DisableAnimations string `json:"disableAnimations" binding:"required"` DisableAnimations string `json:"disableAnimations" binding:"required"`
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"` AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"` AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"` AccentColor string `json:"accentColor"`
SmtpHost string `json:"smtpHost"` SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort string `json:"smtpPort"`

View File

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

View File

@@ -0,0 +1,94 @@
package dto
import (
"net/http"
"reflect"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
"golang.org/x/text/unicode/norm"
)
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
func Normalize(obj any) {
v := reflect.ValueOf(obj)
if v.Kind() != reflect.Ptr || v.IsNil() {
return
}
v = v.Elem()
// Handle case where obj is a slice of models
if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ {
elem := v.Index(i)
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
Normalize(elem.Interface())
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
Normalize(elem.Addr().Interface())
}
}
return
}
if v.Kind() != reflect.Struct {
return
}
// Iterate through all fields looking for those with the "unorm" tag
t := v.Type()
loop:
for i := range t.NumField() {
field := t.Field(i)
unormTag := field.Tag.Get("unorm")
if unormTag == "" {
continue
}
fv := v.Field(i)
if !fv.CanSet() || fv.Kind() != reflect.String {
continue
}
var form norm.Form
switch unormTag {
case "nfc":
form = norm.NFC
case "nfkc":
form = norm.NFKC
case "nfd":
form = norm.NFD
case "nfkd":
form = norm.NFKD
default:
continue loop
}
val := fv.String()
val = form.String(val)
fv.SetString(val)
}
}
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
return ctx.ShouldBindWith(obj, binding.JSON)
}
type NormalizerJSONBinding struct{}
func (NormalizerJSONBinding) Name() string {
return "json"
}
func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error {
// Use the default JSON binder
err := binding.JSON.Bind(req, obj)
if err != nil {
return err
}
// Perform normalization
Normalize(obj)
return nil
}

View File

@@ -0,0 +1,84 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
"golang.org/x/text/unicode/norm"
)
type testDto struct {
Name string `unorm:"nfc"`
Description string `unorm:"nfd"`
Other string
BadForm string `unorm:"bad"`
}
func TestNormalize(t *testing.T) {
input := testDto{
// Is in NFC form already
Name: norm.NFC.String("Café"),
// NFC form will be normalized to NFD
Description: norm.NFC.String("vërø"),
// Should be unchanged
Other: "NöTag",
// Should be unchanged
BadForm: "BåD",
}
Normalize(&input)
assert.Equal(t, norm.NFC.String("Café"), input.Name)
assert.Equal(t, norm.NFD.String("vërø"), input.Description)
assert.Equal(t, "NöTag", input.Other)
assert.Equal(t, "BåD", input.BadForm)
}
func TestNormalizeSlice(t *testing.T) {
obj1 := testDto{
Name: norm.NFC.String("Café1"),
Description: norm.NFC.String("vërø1"),
Other: "NöTag1",
BadForm: "BåD1",
}
obj2 := testDto{
Name: norm.NFD.String("Résumé2"),
Description: norm.NFD.String("accéléré2"),
Other: "NöTag2",
BadForm: "BåD2",
}
t.Run("slice of structs", func(t *testing.T) {
slice := []testDto{obj1, obj2}
Normalize(&slice)
// Verify first element
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
assert.Equal(t, "NöTag1", slice[0].Other)
assert.Equal(t, "BåD1", slice[0].BadForm)
// Verify second element
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
assert.Equal(t, "NöTag2", slice[1].Other)
assert.Equal(t, "BåD2", slice[1].BadForm)
})
t.Run("slice of pointers to structs", func(t *testing.T) {
slice := []*testDto{&obj1, &obj2}
Normalize(&slice)
// Verify first element
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
assert.Equal(t, "NöTag1", slice[0].Other)
assert.Equal(t, "BåD1", slice[0].BadForm)
// Verify second element
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
assert.Equal(t, "NöTag2", slice[1].Other)
assert.Equal(t, "BåD2", slice[1].BadForm)
})
}

View File

@@ -1,9 +1,13 @@
package dto package dto
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type OidcClientMetaDataDto struct { type OidcClientMetaDataDto struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
HasLogo bool `json:"hasLogo"` HasLogo bool `json:"hasLogo"`
LaunchURL *string `json:"launchURL"`
RequiresReauthentication bool `json:"requiresReauthentication"`
} }
type OidcClientDto struct { type OidcClientDto struct {
@@ -25,13 +29,20 @@ type OidcClientWithAllowedGroupsCountDto struct {
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"` AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
} }
type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
}
type OidcClientCreateDto struct { type OidcClientCreateDto struct {
Name string `json:"name" binding:"required,max=50"` OidcClientUpdateDto
CallbackURLs []string `json:"callbackURLs"` ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
} }
type OidcClientCredentialsDto struct { type OidcClientCredentialsDto struct {
@@ -46,12 +57,13 @@ type OidcClientFederatedIdentityDto struct {
} }
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"` ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"` Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL"` CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"` CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"` CodeChallengeMethod string `json:"codeChallengeMethod"`
ReauthenticationToken string `json:"reauthenticationToken"`
} }
type AuthorizeOidcClientResponseDto struct { type AuthorizeOidcClientResponseDto struct {
@@ -145,8 +157,9 @@ type DeviceCodeInfoDto struct {
} }
type AuthorizedOidcClientDto struct { type AuthorizedOidcClientDto struct {
Scope string `json:"scope"` Scope string `json:"scope"`
Client OidcClientMetaDataDto `json:"client"` Client OidcClientMetaDataDto `json:"client"`
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
} }
type OidcClientPreviewDto struct { type OidcClientPreviewDto struct {
@@ -154,3 +167,8 @@ type OidcClientPreviewDto struct {
AccessToken map[string]any `json:"accessToken"` AccessToken map[string]any `json:"accessToken"`
UserInfo map[string]any `json:"userInfo"` UserInfo map[string]any `json:"userInfo"`
} }
type AccessibleOidcClientDto struct {
OidcClientMetaDataDto
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
}

View File

@@ -1,14 +1,13 @@
package dto package dto
import ( import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
type SignupTokenCreateDto struct { type SignupTokenCreateDto struct {
ExpiresAt time.Time `json:"expiresAt" binding:"required"` TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"` UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
} }
type SignupTokenDto struct { type SignupTokenDto struct {

View File

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

View File

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

View File

@@ -1,26 +1,52 @@
package dto package dto
import ( import (
"log"
"regexp" "regexp"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/gin-gonic/gin/binding" "github.com/gin-gonic/gin/binding"
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
return validateUsernameRegex.MatchString(fl.Field().String())
}
func init() { func init() {
if v, ok := binding.Validator.Engine().(*validator.Validate); ok { v := binding.Validator.Engine().(*validator.Validate)
if err := v.RegisterValidation("username", validateUsername); err != nil {
log.Fatalf("Failed to register custom validation: %v", err) // [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
// Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour
// Errors here are development-time ones
err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
return validateUsernameRegex.MatchString(fl.Field().String())
})
if err != nil {
panic("Failed to register custom validation for username: " + err.Error())
}
err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return validateClientIDRegex.MatchString(fl.Field().String())
})
if err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
err = v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
} }
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || ttl.Duration > time.Second && ttl.Duration <= maxTTL
})
if err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
} }
} }

View File

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

View File

@@ -25,6 +25,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true), s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true), s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true), s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true), s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
) )
} }
@@ -104,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
return nil return nil
} }
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
st := j.db.
WithContext(ctx).
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
if st.Error != nil {
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
}
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
return nil
}
// ClearAuditLogs deletes audit logs older than 90 days // ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error { func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
st := j.db. st := j.db.

View File

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

View File

@@ -34,13 +34,15 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
type AppConfig struct { type AppConfig struct {
// General // General
AppName AppConfigVariable `key:"appName,public"` // Public AppName AppConfigVariable `key:"appName,public"` // Public
SessionDuration AppConfigVariable `key:"sessionDuration"` SessionDuration AppConfigVariable `key:"sessionDuration"`
EmailsVerified AppConfigVariable `key:"emailsVerified"` EmailsVerified AppConfigVariable `key:"emailsVerified"`
AccentColor AppConfigVariable `key:"accentColor,public"` // Public AccentColor AppConfigVariable `key:"accentColor,public"` // Public
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
// Internal // Internal
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
@@ -178,7 +180,7 @@ type AppConfigKeyNotFoundError struct {
} }
func (e AppConfigKeyNotFoundError) Error() string { func (e AppConfigKeyNotFoundError) Error() string {
return fmt.Sprintf("cannot find config key '%s'", e.field) return "cannot find config key '" + e.field + "'"
} }
func (e AppConfigKeyNotFoundError) Is(target error) bool { func (e AppConfigKeyNotFoundError) Is(target error) bool {
@@ -192,7 +194,7 @@ type AppConfigInternalForbiddenError struct {
} }
func (e AppConfigInternalForbiddenError) Error() string { func (e AppConfigInternalForbiddenError) Error() string {
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field) return "field '" + e.field + "' is internal and can't be updated"
} }
func (e AppConfigInternalForbiddenError) Is(target error) bool { func (e AppConfigInternalForbiddenError) Is(target error) bool {

View File

@@ -11,7 +11,9 @@ import (
) )
type UserAuthorizedOidcClient struct { type UserAuthorizedOidcClient struct {
Scope string Scope string
LastUsedAt datatype.DateTime `sortable:"true"`
UserID string `gorm:"primary_key;"` UserID string `gorm:"primary_key;"`
User User User User
@@ -38,19 +40,22 @@ type OidcAuthorizationCode struct {
type OidcClient struct { type OidcClient struct {
Base Base
Name string `sortable:"true"` Name string `sortable:"true"`
Secret string Secret string
CallbackURLs UrlList CallbackURLs UrlList
LogoutCallbackURLs UrlList LogoutCallbackURLs UrlList
ImageType *string ImageType *string
HasLogo bool `gorm:"-"` HasLogo bool `gorm:"-"`
IsPublic bool IsPublic bool
PkceEnabled bool PkceEnabled bool
Credentials OidcClientCredentials RequiresReauthentication bool
Credentials OidcClientCredentials
LaunchURL *string
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"` AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID string CreatedByID *string
CreatedBy User CreatedBy *User
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
} }
type OidcRefreshToken struct { type OidcRefreshToken struct {

View File

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

View File

@@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
apiKey := model.ApiKey{ apiKey := model.ApiKey{
Name: input.Name, Name: input.Name,
Key: utils.CreateSha256Hash(token), // Hash the token for storage Key: utils.CreateSha256Hash(token), // Hash the token for storage
Description: &input.Description, Description: input.Description,
ExpiresAt: datatype.DateTime(input.ExpiresAt), ExpiresAt: input.ExpiresAt,
UserID: userID, UserID: userID,
} }

View File

@@ -4,17 +4,14 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log"
"mime/multipart" "mime/multipart"
"os" "os"
"reflect" "reflect"
"slices"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
"github.com/hashicorp/go-uuid" "github.com/hashicorp/go-uuid"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -29,22 +26,22 @@ type AppConfigService struct {
db *gorm.DB db *gorm.DB
} }
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService { func NewAppConfigService(ctx context.Context, db *gorm.DB) (*AppConfigService, error) {
service := &AppConfigService{ service := &AppConfigService{
db: db, db: db,
} }
err := service.LoadDbConfig(ctx) err := service.LoadDbConfig(ctx)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize app config service: %v", err) return nil, fmt.Errorf("failed to initialize app config service: %w", err)
} }
err = service.initInstanceID(ctx) err = service.initInstanceID(ctx)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize instance ID: %v", err) return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
} }
return service return service, nil
} }
// GetDbConfig returns the application configuration. // GetDbConfig returns the application configuration.
@@ -63,13 +60,15 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// Values are the default ones // Values are the default ones
return &model.AppConfig{ return &model.AppConfig{
// General // General
AppName: model.AppConfigVariable{Value: "Pocket ID"}, AppName: model.AppConfigVariable{Value: "Pocket ID"},
SessionDuration: model.AppConfigVariable{Value: "60"}, SessionDuration: model.AppConfigVariable{Value: "60"},
EmailsVerified: model.AppConfigVariable{Value: "false"}, EmailsVerified: model.AppConfigVariable{Value: "false"},
DisableAnimations: model.AppConfigVariable{Value: "false"}, DisableAnimations: model.AppConfigVariable{Value: "false"},
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"}, AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
AllowUserSignups: model.AppConfigVariable{Value: "disabled"}, AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
AccentColor: model.AppConfigVariable{Value: "default"}, SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"},
// Internal // Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"},
@@ -414,12 +413,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
field := rt.Field(i) field := rt.Field(i)
// Get the key and internal tag values // Get the key and internal tag values
tagValue := strings.Split(field.Tag.Get("key"), ",") key, attrs, _ := strings.Cut(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 // Internal fields are loaded from the database as they can't be set from the environment
if isInternal { if attrs == "internal" {
var value string var value string
err := tx.WithContext(ctx). err := tx.WithContext(ctx).
Model(&model.AppConfigVariable{}). Model(&model.AppConfigVariable{}).
@@ -438,6 +435,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
value, ok := os.LookupEnv(envVarName) value, ok := os.LookupEnv(envVarName)
if ok { if ok {
rv.Field(i).FieldByName("Value").SetString(value) rv.Field(i).FieldByName("Value").SetString(value)
continue
}
// If it's sensitive, we also allow reading from file
if attrs == "sensitive" {
fileName := os.Getenv(envVarName + "_FILE")
if fileName != "" {
b, err := os.ReadFile(fileName)
if err != nil {
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
}
rv.Field(i).FieldByName("Value").SetString(string(b))
continue
}
} }
} }

View File

@@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
userAgentParser "github.com/mileusna/useragent" userAgentParser "github.com/mileusna/useragent"
@@ -11,6 +10,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model" "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"
"github.com/pocket-id/pocket-id/backend/internal/utils/email" "github.com/pocket-id/pocket-id/backend/internal/utils/email"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -22,7 +22,12 @@ type AuditLogService struct {
} }
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService { func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService} return &AuditLogService{
db: db,
appConfigService: appConfigService,
emailService: emailService,
geoliteService: geoliteService,
}
} }
// Create creates a new audit log entry in the database // Create creates a new audit log entry in the database
@@ -70,14 +75,19 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
// Count the number of times the user has logged in from the same device // Count the number of times the user has logged in from the same device
var count int64 var count int64
err := tx. stmt := tx.
WithContext(ctx). WithContext(ctx).
Model(&model.AuditLog{}). Model(&model.AuditLog{}).
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent). Where("user_id = ? AND user_agent = ?", userID, userAgent)
Count(&count). if ipAddress == "" {
Error // An empty IP address is stored as NULL in the database
stmt = stmt.Where("ip_address IS NULL")
} else {
stmt = stmt.Where("ip_address = ?", ipAddress)
}
err := stmt.Count(&count).Error
if err != nil { if err != nil {
log.Printf("Failed to count audit logs: %v", err) slog.ErrorContext(ctx, "Failed to count audit logs", slog.Any("error", err))
return createdAuditLog return createdAuditLog
} }
@@ -86,7 +96,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
// We use a background context here as this is running in a goroutine // We use a background context here as this is running in a goroutine
//nolint:contextcheck //nolint:contextcheck
go func() { go func() {
innerCtx := context.Background() span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
// Note we don't use the transaction here because this is running in background // Note we don't use the transaction here because this is running in background
var user model.User var user model.User
@@ -96,7 +107,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
First(&user). First(&user).
Error Error
if innerErr != nil { if innerErr != nil {
log.Printf("Failed to load user: %v", innerErr) slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
return
} }
innerErr = SendEmail(innerCtx, s.emailService, email.Address{ innerErr = SendEmail(innerCtx, s.emailService, email.Address{
@@ -110,7 +122,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
DateTime: createdAuditLog.CreatedAt.UTC(), DateTime: createdAuditLog.CreatedAt.UTC(),
}) })
if innerErr != nil { if innerErr != nil {
log.Printf("Failed to send email to '%s': %v", user.Email, innerErr) slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", user.Email))
return
} }
}() }()
} }

View File

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

View File

@@ -10,7 +10,7 @@ import (
"crypto/x509" "crypto/x509"
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"log" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
@@ -154,11 +154,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
}, },
Name: "Nextcloud", Name: "Nextcloud",
LaunchURL: utils.Ptr("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"}, CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"}, LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: utils.StringPointer("png"), ImageType: utils.StringPointer("png"),
CreatedByID: users[0].ID, CreatedByID: utils.Ptr(users[0].ID),
}, },
{ {
Base: model.Base{ Base: model.Base{
@@ -167,11 +168,21 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Immich", Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"}, CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: users[1].ID, CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{ AllowedUserGroups: []model.UserGroup{
userGroups[1], userGroups[1],
}, },
}, },
{
Base: model.Base{
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
},
Name: "Tailscale",
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
CreatedByID: utils.Ptr(users[0].ID),
},
{ {
Base: model.Base{ Base: model.Base{
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b", ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
@@ -179,7 +190,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Federated", Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"}, CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: users[1].ID, CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{}, AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{ Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{ FederatedIdentities: []model.OidcClientFederatedIdentity{
@@ -245,14 +256,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
userAuthorizedClients := []model.UserAuthorizedOidcClient{ userAuthorizedClients := []model.UserAuthorizedOidcClient{
{ {
Scope: "openid profile email", Scope: "openid profile email",
UserID: users[0].ID, UserID: users[0].ID,
ClientID: oidcClients[0].ID, ClientID: oidcClients[0].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
}, },
{ {
Scope: "openid profile email", Scope: "openid profile email",
UserID: users[1].ID, UserID: users[0].ID,
ClientID: oidcClients[2].ID, ClientID: oidcClients[2].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
},
{
Scope: "openid profile email",
UserID: users[1].ID,
ClientID: oidcClients[3].ID,
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
}, },
} }
for _, userAuthorizedClient := range userAuthorizedClients { for _, userAuthorizedClient := range userAuthorizedClients {
@@ -324,7 +343,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901", ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
}, },
Token: "PARTIAL567890ABC", Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)), ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
@@ -333,7 +352,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012", ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
}, },
Token: "EXPIRED34567890B", Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
@@ -342,7 +361,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "d4e5f6g7-h8i9-0123-def0-234567890123", ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
}, },
Token: "FULLYUSED567890C", Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
@@ -402,9 +421,9 @@ func (s *TestService) ResetDatabase() error {
return err return err
} }
func (s *TestService) ResetApplicationImages() error { func (s *TestService) ResetApplicationImages(ctx context.Context) error {
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil { if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
log.Printf("Error removing directory: %v", err) slog.ErrorContext(ctx, "Error removing directory", slog.Any("error", err))
return err return err
} }

View File

@@ -7,7 +7,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net" "net"
"net/http" "net/http"
"net/netip" "net/netip"
@@ -52,13 +52,14 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl { if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
// Warn the user, and disable the periodic updater // Warn the user, and disable the periodic updater
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.") slog.Warn("MAXMIND_LICENSE_KEY environment variable is empty: the GeoLite2 City database won't be updated")
service.disableUpdater = true service.disableUpdater = true
} }
// Initialize IPv6 local ranges // Initialize IPv6 local ranges
if err := service.initializeIPv6LocalRanges(); err != nil { err := service.initializeIPv6LocalRanges()
log.Printf("Warning: Failed to initialize IPv6 local ranges: %v", err) if err != nil {
slog.Warn("Failed to initialize IPv6 local ranges", slog.Any("error", err))
} }
return service return service
@@ -96,7 +97,7 @@ func (s *GeoLiteService) initializeIPv6LocalRanges() error {
s.localIPv6Ranges = localRanges s.localIPv6Ranges = localRanges
if len(localRanges) > 0 { if len(localRanges) > 0 {
log.Printf("Initialized %d IPv6 local ranges", len(localRanges)) slog.Info("Initialized IPv6 local ranges", slog.Int("count", len(localRanges)))
} }
return nil return nil
} }
@@ -186,11 +187,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days. // UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error { func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
if s.isDatabaseUpToDate() { if s.isDatabaseUpToDate() {
log.Println("GeoLite2 City database is up-to-date") slog.Info("GeoLite2 City database is up-to-date")
return nil return nil
} }
log.Println("Updating GeoLite2 City database") slog.Info("Updating GeoLite2 City database")
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey) downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute) ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
@@ -217,7 +218,7 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
return fmt.Errorf("failed to extract database: %w", err) return fmt.Errorf("failed to extract database: %w", err)
} }
log.Println("GeoLite2 City database successfully updated.") slog.Info("GeoLite2 City database successfully updated.")
return nil return nil
} }

View File

@@ -5,7 +5,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"time" "time"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
@@ -64,16 +63,16 @@ type JwtService struct {
jwksEncoded []byte jwksEncoded []byte
} }
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService { func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{} service := &JwtService{}
// Ensure keys are generated or loaded // Ensure keys are generated or loaded
err := service.init(db, appConfigService, &common.EnvConfig) err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil { if err != nil {
log.Fatalf("Failed to initialize jwt service: %v", err) return nil, err
} }
return service return service, nil
} }
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) { func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {

View File

@@ -8,20 +8,21 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strings" "strings"
"time" "time"
"unicode/utf8" "unicode/utf8"
"github.com/google/uuid"
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
) )
type LdapService struct { type LdapService struct {
@@ -129,7 +130,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
// Skip groups without a valid LDAP ID // Skip groups without a valid LDAP ID
if ldapId == "" { if ldapId == "" {
log.Printf("Skipping LDAP group without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeGroupUniqueIdentifier.Value) slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
continue continue
} }
@@ -167,13 +168,13 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
userResult, err := client.Search(userSearchReq) userResult, err := client.Search(userSearchReq)
if err != nil || len(userResult.Entries) == 0 { if err != nil || len(userResult.Entries) == 0 {
log.Printf("Could not resolve group member DN '%s': %v", member, err) slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
continue continue
} }
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value) username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
if username == "" { if username == "" {
log.Printf("Could not extract username from group member DN '%s'", member) slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
continue continue
} }
} }
@@ -181,7 +182,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
var databaseUser model.User var databaseUser model.User
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", username). Where("username = ? AND ldap_id IS NOT NULL", norm.NFC.String(username)).
First(&databaseUser). First(&databaseUser).
Error Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -199,6 +200,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapId, LdapID: ldapId,
} }
dto.Normalize(syncGroup)
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx) newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
@@ -248,7 +250,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err) return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
} }
log.Printf("Deleted group '%s'", group.Name) slog.Info("Deleted group", slog.String("group", group.Name))
} }
return nil return nil
@@ -293,7 +295,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// Skip users without a valid LDAP ID // Skip users without a valid LDAP ID
if ldapId == "" { if ldapId == "" {
log.Printf("Skipping LDAP user without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeUserUniqueIdentifier.Value) slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
continue continue
} }
@@ -309,7 +311,6 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
// If a user is found (even if disabled), enable them since they're now back in LDAP // If a user is found (even if disabled), enable them since they're now back in LDAP
if databaseUser.ID != "" && databaseUser.Disabled { if databaseUser.ID != "" && databaseUser.Disabled {
// Use the transaction instead of the direct context
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Model(&model.User{}). Model(&model.User{}).
@@ -318,7 +319,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
Error Error
if err != nil { if err != nil {
log.Printf("Failed to enable user %s: %v", databaseUser.Username, err) return fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
} }
} }
@@ -344,11 +345,12 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
IsAdmin: isAdmin, IsAdmin: isAdmin,
LdapID: ldapId, LdapID: ldapId,
} }
dto.Normalize(newUser)
if databaseUser.ID == "" { if databaseUser.ID == "" {
_, err = s.userService.createUserInternal(ctx, newUser, true, tx) _, err = s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) { if errors.Is(err, &common.AlreadyInUseError{}) {
log.Printf("Skipping creating LDAP user '%s': %v", newUser.Username, err) slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue continue
} else if err != nil { } else if err != nil {
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err) return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
@@ -356,7 +358,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
} else { } else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx) _, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) { if errors.Is(err, &common.AlreadyInUseError{}) {
log.Printf("Skipping updating LDAP user '%s': %v", newUser.Username, err) slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue continue
} else if err != nil { } else if err != nil {
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err) return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
@@ -369,7 +371,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString) err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
if err != nil { if err != nil {
// This is not a fatal error // This is not a fatal error
log.Printf("Error saving profile picture for user %s: %v", newUser.Username, err) slog.Warn("Error saving profile picture for user", slog.String("username", newUser.Username), slog.Any("error", err))
} }
} }
} }
@@ -398,7 +400,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
return fmt.Errorf("failed to disable user %s: %w", user.Username, err) return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
} }
log.Printf("Disabled user '%s'", user.Username) slog.Info("Disabled user", slog.String("username", user.Username))
} else { } else {
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx) err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
target := &common.LdapUserUpdateError{} target := &common.LdapUserUpdateError{}
@@ -408,7 +410,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
return fmt.Errorf("failed to delete user %s: %w", user.Username, err) return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
} }
log.Printf("Deleted user '%s'", user.Username) slog.Info("Deleted user", slog.String("username", user.Username))
} }
} }
@@ -476,7 +478,7 @@ func getDNProperty(property string, str string) string {
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data. // LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
func convertLdapIdToString(ldapId string) string { func convertLdapIdToString(ldapId string) string {
if utf8.ValidString(ldapId) { if utf8.ValidString(ldapId) {
return ldapId return norm.NFC.String(ldapId)
} }
// Try to parse as binary UUID (16 bytes) // Try to parse as binary UUID (16 bytes)

View File

@@ -8,7 +8,6 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -51,6 +50,7 @@ type OidcService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
auditLogService *AuditLogService auditLogService *AuditLogService
customClaimService *CustomClaimService customClaimService *CustomClaimService
webAuthnService *WebAuthnService
httpClient *http.Client httpClient *http.Client
jwkCache *jwk.Cache jwkCache *jwk.Cache
@@ -63,6 +63,7 @@ func NewOidcService(
appConfigService *AppConfigService, appConfigService *AppConfigService,
auditLogService *AuditLogService, auditLogService *AuditLogService,
customClaimService *CustomClaimService, customClaimService *CustomClaimService,
webAuthnService *WebAuthnService,
) (s *OidcService, err error) { ) (s *OidcService, err error) {
s = &OidcService{ s = &OidcService{
db: db, db: db,
@@ -70,6 +71,7 @@ func NewOidcService(
appConfigService: appConfigService, appConfigService: appConfigService,
auditLogService: auditLogService, auditLogService: auditLogService,
customClaimService: customClaimService, customClaimService: customClaimService,
webAuthnService: webAuthnService,
} }
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace // Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
@@ -124,6 +126,16 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err return "", "", err
} }
if client.RequiresReauthentication {
if input.ReauthenticationToken == "" {
return "", "", &common.ReauthenticationRequiredError{}
}
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
if err != nil {
return "", "", err
}
}
// If the client is not public, the code challenge must be provided // If the client is not public, the code challenge must be provided
if client.IsPublic && input.CodeChallenge == "" { if client.IsPublic && input.CodeChallenge == "" {
return "", "", &common.OidcMissingCodeChallengeError{} return "", "", &common.OidcMissingCodeChallengeError{}
@@ -150,20 +162,11 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", &common.OidcAccessDeniedError{} return "", "", &common.OidcAccessDeniedError{}
} }
// Check if the user has already authorized the client with the given scope hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
if err != nil { if err != nil {
return "", "", err return "", "", err
} }
// If the user has not authorized the client, create a new authorization in the database
if !hasAuthorizedClient {
err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
if err != nil {
return "", "", err
}
}
// Create the authorization code // Create the authorization code
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx) code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
if err != nil { if err != nil {
@@ -171,7 +174,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
} }
// Log the authorization event // Log the authorization event
if hasAuthorizedClient { if hasAlreadyAuthorizedClient {
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx) s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
} else { } else {
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx) s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
@@ -651,8 +654,7 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
} }
// As allowedUserGroupsCount is not a column, we need to manually sort it // As allowedUserGroupsCount is not a column, we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc" if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && isValidSortDirection {
query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)"). query = query.Select("oidc_clients.*, COUNT(oidc_clients_allowed_user_groups.oidc_client_id)").
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id"). Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Group("oidc_clients.id"). Group("oidc_clients.id").
@@ -668,22 +670,28 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) { func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
client := model.OidcClient{ client := model.OidcClient{
CreatedByID: userID, Base: model.Base{
ID: input.ID,
},
CreatedByID: utils.Ptr(userID),
} }
updateOIDCClientModelFromDto(&client, &input) updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
err := s.db. err := s.db.
WithContext(ctx). WithContext(ctx).
Create(&client). Create(&client).
Error Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.OidcClient{}, &common.ClientIdAlreadyExistsError{}
}
return model.OidcClient{}, err return model.OidcClient{}, err
} }
return client, nil return client, nil
} }
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) { func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
@@ -717,7 +725,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return client, nil return client, nil
} }
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientCreateDto) { func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
// Base fields // Base fields
client.Name = input.Name client.Name = input.Name
client.CallbackURLs = input.CallbackURLs client.CallbackURLs = input.CallbackURLs
@@ -725,19 +733,20 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.IsPublic = input.IsPublic client.IsPublic = input.IsPublic
// PKCE is required for public clients // PKCE is required for public clients
client.PkceEnabled = input.IsPublic || input.PkceEnabled client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
client.LaunchURL = input.LaunchURL
// Credentials // Credentials
if len(input.Credentials.FederatedIdentities) > 0 { client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities)) for i, fi := range input.Credentials.FederatedIdentities {
for i, fi := range input.Credentials.FederatedIdentities { client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{ Issuer: fi.Issuer,
Issuer: fi.Issuer, Audience: fi.Audience,
Audience: fi.Audience, Subject: fi.Subject,
Subject: fi.Subject, JWKS: fi.JWKS,
JWKS: fi.JWKS,
}
} }
} }
} }
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error { func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
@@ -1180,9 +1189,13 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
}() }()
var deviceAuth model.OidcDeviceCode var deviceAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).Preload("Client.AllowedUserGroups").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil { err := tx.
log.Printf("Error finding device code with user_code %s: %v", userCode, err) WithContext(ctx).
return err Preload("Client.AllowedUserGroups").
First(&deviceAuth, "user_code = ?", userCode).
Error
if err != nil {
return fmt.Errorf("error finding device code: %w", err)
} }
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
@@ -1191,17 +1204,26 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
// Check if the user group is allowed to authorize the client // Check if the user group is allowed to authorize the client
var user model.User var user model.User
if err := tx.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil { err = tx.
return err WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return fmt.Errorf("error finding user groups: %w", err)
} }
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) { if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
return &common.OidcAccessDeniedError{} return &common.OidcAccessDeniedError{}
} }
if err := tx.WithContext(ctx).Preload("Client").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil { err = tx.
log.Printf("Error finding device code with user_code %s: %v", userCode, err) WithContext(ctx).
return err Preload("Client").
First(&deviceAuth, "user_code = ?", userCode).
Error
if err != nil {
return fmt.Errorf("error finding device code: %w", err)
} }
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) { if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
@@ -1211,33 +1233,24 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
deviceAuth.UserID = &userID deviceAuth.UserID = &userID
deviceAuth.IsAuthorized = true deviceAuth.IsAuthorized = true
if err := tx.WithContext(ctx).Save(&deviceAuth).Error; err != nil { err = tx.
log.Printf("Error saving device auth: %v", err) WithContext(ctx).
return err Save(&deviceAuth).
Error
if err != nil {
return fmt.Errorf("error saving device auth: %w", err)
} }
// Verify the update was successful hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
var verifiedAuth model.OidcDeviceCode
if err := tx.WithContext(ctx).First(&verifiedAuth, "device_code = ?", deviceAuth.DeviceCode).Error; err != nil {
log.Printf("Error verifying update: %v", err)
return err
}
// Create user authorization if needed
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx)
if err != nil { if err != nil {
return err return err
} }
if !hasAuthorizedClient { auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name}
err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx) if hasAlreadyAuthorizedClient {
if err != nil { s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
return err
}
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
} else { } else {
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx) s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
} }
return tx.Commit().Error return tx.Commit().Error
@@ -1313,6 +1326,108 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
return authorizedClients, response, err return authorizedClients, response, err
} }
func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, clientID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var authorizedClient model.UserAuthorizedOidcClient
err := tx.
WithContext(ctx).
Where("user_id = ? AND client_id = ?", userID, clientID).
First(&authorizedClient).Error
if err != nil {
return err
}
err = tx.WithContext(ctx).Delete(&authorizedClient).Error
if err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
return nil
}
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var user model.User
err := tx.
WithContext(ctx).
Preload("UserGroups").
First(&user, "id = ?", userID).
Error
if err != nil {
return nil, utils.PaginationResponse{}, err
}
userGroupIDs := make([]string, len(user.UserGroups))
for i, group := range user.UserGroups {
userGroupIDs[i] = group.ID
}
// Build the query for accessible clients
query := tx.
WithContext(ctx).
Model(&model.OidcClient{}).
Preload("UserAuthorizedOidcClients", "user_id = ?", userID)
// If user has no groups, only return clients with no allowed user groups
if len(userGroupIDs) == 0 {
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL")
} else {
// Return clients with no allowed user groups OR clients where user is in allowed groups
query = query.
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id").
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL OR oidc_clients_allowed_user_groups.user_group_id IN (?)", userGroupIDs)
}
var clients []model.OidcClient
// Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction + " NULLS LAST")
}
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
if err != nil {
return nil, utils.PaginationResponse{}, err
}
dtos := make([]dto.AccessibleOidcClientDto, len(clients))
for i, client := range clients {
var lastUsedAt *datatype.DateTime
if len(client.UserAuthorizedOidcClients) > 0 {
lastUsedAt = &client.UserAuthorizedOidcClients[0].LastUsedAt
}
dtos[i] = dto.AccessibleOidcClientDto{
OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
ID: client.ID,
Name: client.Name,
LaunchURL: client.LaunchURL,
HasLogo: client.HasLogo,
},
LastUsedAt: lastUsedAt,
}
}
return dtos, response, err
}
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) { func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
refreshToken, err := utils.GenerateRandomAlphanumericString(40) refreshToken, err := utils.GenerateRandomAlphanumericString(40)
if err != nil { if err != nil {
@@ -1348,14 +1463,37 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
return signed, nil return signed, nil
} }
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error { func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) {
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID, // Check if the user has already authorized the client with the given scope
ClientID: clientID, hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx)
Scope: scope, if err != nil {
return false, err
} }
err := tx.WithContext(ctx). if hasAlreadyAuthorizedClient {
err = tx.
WithContext(ctx).
Model(&model.UserAuthorizedOidcClient{}).
Where("user_id = ? AND client_id = ?", userID, clientID).
Update("last_used_at", datatype.DateTime(time.Now())).
Error
if err != nil {
return hasAlreadyAuthorizedClient, err
}
return hasAlreadyAuthorizedClient, nil
}
userAuthorizedClient := model.UserAuthorizedOidcClient{
UserID: userID,
ClientID: clientID,
Scope: scope,
LastUsedAt: datatype.DateTime(time.Now()),
}
err = tx.WithContext(ctx).
Clauses(clause.OnConflict{ Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}}, Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
DoUpdates: clause.AssignmentColumns([]string{"scope"}), DoUpdates: clause.AssignmentColumns([]string{"scope"}),
@@ -1363,7 +1501,7 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
Create(&userAuthorizedClient). Create(&userAuthorizedClient).
Error Error
return err return hasAlreadyAuthorizedClient, err
} }
type ClientAuthCredentials struct { type ClientAuthCredentials struct {
@@ -1416,8 +1554,8 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
// Validate credentials based on the authentication method // Validate credentials based on the authentication method
switch { switch {
// First, if we have a client secret, we validate it // First, if we have a client secret, we validate it unless client is marked as public
case input.ClientSecret != "": case input.ClientSecret != "" && !client.IsPublic:
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret)) err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret))
if err != nil { if err != nil {
return nil, &common.OidcClientSecretInvalidError{} return nil, &common.OidcClientSecretInvalidError{}
@@ -1428,7 +1566,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
case isClientAssertion: case isClientAssertion:
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input) err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil { if err != nil {
log.Printf("Invalid assertion for client '%s': %v", client.ID, err) slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
return nil, &common.OidcClientAssertionInvalidError{} return nil, &common.OidcClientAssertionInvalidError{}
} }
return client, nil return client, nil
@@ -1695,3 +1833,19 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
return claims, nil return claims, nil
} }
func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) {
var user model.User
err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error
if err != nil {
return false, err
}
var client model.OidcClient
err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error
if err != nil {
return false, err
}
return s.IsUserGroupAllowedToAuthorize(user, client), nil
}

View File

@@ -171,8 +171,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Create the test clients // Create the test clients
// 1. Confidential client // 1. Confidential client
confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Confidential Client", OidcClientUpdateDto: dto.OidcClientUpdateDto{
CallbackURLs: []string{"https://example.com/callback"}, Name: "Confidential Client",
CallbackURLs: []string{"https://example.com/callback"},
},
}, "test-user-id") }, "test-user-id")
require.NoError(t, err) require.NoError(t, err)
@@ -182,20 +184,24 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// 2. Public client // 2. Public client
publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Public Client", OidcClientUpdateDto: dto.OidcClientUpdateDto{
CallbackURLs: []string{"https://example.com/callback"}, Name: "Public Client",
IsPublic: true, CallbackURLs: []string{"https://example.com/callback"},
IsPublic: true,
},
}, "test-user-id") }, "test-user-id")
require.NoError(t, err) require.NoError(t, err)
// 3. Confidential client with federated identity // 3. Confidential client with federated identity
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{ federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
Name: "Federated Client", OidcClientUpdateDto: dto.OidcClientUpdateDto{
CallbackURLs: []string{"https://example.com/callback"}, Name: "Federated Client",
CallbackURLs: []string{"https://example.com/callback"},
},
}, "test-user-id") }, "test-user-id")
require.NoError(t, err) require.NoError(t, err)
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientCreateDto{ federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientUpdateDto{
Name: federatedClient.Name, Name: federatedClient.Name,
CallbackURLs: federatedClient.CallbackURLs, CallbackURLs: federatedClient.CallbackURLs,
Credentials: dto.OidcClientCredentialsDto{ Credentials: dto.OidcClientCredentialsDto{

View File

@@ -32,8 +32,7 @@ func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginati
} }
// As userCount is not a column we need to manually sort it // As userCount is not a column we need to manually sort it
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc" if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)"). query = query.Select("user_groups.*, COUNT(user_groups_users.user_id)").
Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id"). Joins("LEFT JOIN user_groups_users ON user_groups.id = user_groups_users.user_group_id").
Group("user_groups.id"). Group("user_groups.id").

View File

@@ -3,16 +3,18 @@ package service
import ( import (
"bytes" "bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log" "log/slog"
"net/url" "net/url"
"os" "os"
"strings" "strings"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -25,15 +27,23 @@ import (
) )
type UserService struct { type UserService struct {
db *gorm.DB db *gorm.DB
jwtService *JwtService jwtService *JwtService
auditLogService *AuditLogService auditLogService *AuditLogService
emailService *EmailService emailService *EmailService
appConfigService *AppConfigService appConfigService *AppConfigService
customClaimService *CustomClaimService
} }
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService {
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService} return &UserService{
db: db,
jwtService: jwtService,
auditLogService: auditLogService,
emailService: emailService,
appConfigService: appConfigService,
customClaimService: customClaimService,
}
} }
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) { func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
@@ -45,7 +55,8 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
if searchTerm != "" { if searchTerm != "" {
searchPattern := "%" + searchTerm + "%" searchPattern := "%" + searchTerm + "%"
query = query.Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?", query = query.Where(
"email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
searchPattern, searchPattern, searchPattern, searchPattern) searchPattern, searchPattern, searchPattern, searchPattern)
} }
@@ -118,13 +129,14 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
defaultPictureBytes := defaultPicture.Bytes() defaultPictureBytes := defaultPicture.Bytes()
go func() { go func() {
// Ensure the directory exists // Ensure the directory exists
err = os.MkdirAll(defaultProfilePicturesDir, os.ModePerm) errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
if err != nil { if errInternal != nil {
log.Printf("Failed to create directory for default profile picture: %v", err) slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
return return
} }
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil { errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath)
log.Printf("Failed to cache default profile picture for initials %s: %v", user.Initials(), err) if errInternal != nil {
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
} }
}() }()
@@ -259,9 +271,53 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
} else if err != nil { } else if err != nil {
return model.User{}, err return model.User{}, err
} }
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if err := s.applySignupDefaults(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
return user, nil return user, nil
} }
func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
// Apply default user groups
var groupIDs []string
if v := config.SignupDefaultUserGroupIDs.Value; v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &groupIDs); err != nil {
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
}
if len(groupIDs) > 0 {
var groups []model.UserGroup
if err := tx.WithContext(ctx).Where("id IN ?", groupIDs).Find(&groups).Error; err != nil {
return fmt.Errorf("failed to find default user groups: %w", err)
}
if err := tx.WithContext(ctx).Model(user).Association("UserGroups").Replace(groups); err != nil {
return fmt.Errorf("failed to associate default user groups: %w", err)
}
}
}
// Apply default custom claims
var claims []dto.CustomClaimCreateDto
if v := config.SignupDefaultCustomClaims.Value; v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &claims); err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
if len(claims) > 0 {
if _, err := s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx); err != nil {
return fmt.Errorf("failed to apply default custom claims: %w", err)
}
}
}
return nil
}
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) { func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
@@ -339,13 +395,13 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, nil return user, nil
} }
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error { func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue() isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
if isDisabled { if isDisabled {
return &common.OneTimeAccessDisabledError{} return &common.OneTimeAccessDisabledError{}
} }
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration) return s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl)
} }
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error { func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
@@ -365,11 +421,10 @@ func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context
} }
} }
expiration := time.Now().Add(15 * time.Minute) return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
} }
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error { func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration) error {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
@@ -380,7 +435,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
return err return err
} }
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx) oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
if err != nil { if err != nil {
return err return err
} }
@@ -393,7 +448,8 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
// We use a background context here as this is running in a goroutine // We use a background context here as this is running in a goroutine
//nolint:contextcheck //nolint:contextcheck
go func() { go func() {
innerCtx := context.Background() span := trace.SpanFromContext(ctx)
innerCtx := trace.ContextWithSpan(context.Background(), span)
link := common.EnvConfig.AppURL + "/lc" link := common.EnvConfig.AppURL + "/lc"
linkWithCode := link + "/" + oneTimeAccessToken linkWithCode := link + "/" + oneTimeAccessToken
@@ -411,27 +467,29 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
Code: oneTimeAccessToken, Code: oneTimeAccessToken,
LoginLink: link, LoginLink: link,
LoginLinkWithCode: linkWithCode, LoginLinkWithCode: linkWithCode,
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)), ExpirationString: utils.DurationToString(ttl),
}) })
if errInternal != nil { if errInternal != nil {
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal) slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", user.Email))
return
} }
}() }()
return nil return nil
} }
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, expiresAt time.Time) (string, error) { func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error) {
return s.createOneTimeAccessTokenInternal(ctx, userID, expiresAt, s.db) return s.createOneTimeAccessTokenInternal(ctx, userID, ttl, s.db)
} }
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) { func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, tx *gorm.DB) (string, error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt) oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl)
if err != nil { if err != nil {
return "", err return "", err
} }
if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil { err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", err return "", err
} }
@@ -469,9 +527,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token stri
return model.User{}, "", err return model.User{}, "", err
} }
if ipAddress != "" && userAgent != "" { s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
}
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
@@ -495,7 +551,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
// Fetch the groups based on userGroupIds // Fetch the groups based on userGroupIds
var groups []model.UserGroup var groups []model.UserGroup
if len(userGroupIds) > 0 { if len(userGroupIds) > 0 {
err = tx. err := tx.
WithContext(ctx). WithContext(ctx).
Where("id IN (?)", userGroupIds). Where("id IN (?)", userGroupIds).
Find(&groups). Find(&groups).
@@ -633,17 +689,14 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
Error Error
} }
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) { func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) {
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db) signupToken, err := NewSignupToken(ttl, usageLimit)
}
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
signupToken, err := NewSignupToken(expiresAt, usageLimit)
if err != nil { if err != nil {
return model.SignupToken{}, err return model.SignupToken{}, err
} }
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil { err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err return model.SignupToken{}, err
} }
@@ -737,10 +790,10 @@ func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) err
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
} }
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) { func NewOneTimeAccessToken(userID string, ttl time.Duration) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16 // If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16 tokenLength := 16
if time.Until(expiresAt) <= 15*time.Minute { if ttl <= 15*time.Minute {
tokenLength = 6 tokenLength = 6
} }
@@ -749,25 +802,27 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
return nil, err return nil, err
} }
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{ o := &model.OneTimeAccessToken{
UserID: userID, UserID: userID,
ExpiresAt: datatype.DateTime(expiresAt), ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: randomString, Token: randomString,
} }
return o, nil return o, nil
} }
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) { func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
// Generate a random token // Generate a random token
randomString, err := utils.GenerateRandomAlphanumericString(16) randomString, err := utils.GenerateRandomAlphanumericString(16)
if err != nil { if err != nil {
return nil, err return nil, err
} }
now := time.Now().Round(time.Second)
token := &model.SignupToken{ token := &model.SignupToken{
Token: randomString, Token: randomString,
ExpiresAt: datatype.DateTime(expiresAt), ExpiresAt: datatype.DateTime(now.Add(ttl)),
UsageLimit: usageLimit, UsageLimit: usageLimit,
UsageCount: 0, UsageCount: 0,
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/go-webauthn/webauthn/protocol" "github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn" "github.com/go-webauthn/webauthn/webauthn"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
@@ -24,8 +25,8 @@ type WebAuthnService struct {
appConfigService *AppConfigService appConfigService *AppConfigService
} }
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService { func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) (*WebAuthnService, error) {
webauthnConfig := &webauthn.Config{ wa, err := webauthn.New(&webauthn.Config{
RPDisplayName: appConfigService.GetDbConfig().AppName.Value, RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL), RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
RPOrigins: []string{common.EnvConfig.AppURL}, RPOrigins: []string{common.EnvConfig.AppURL},
@@ -44,15 +45,18 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
TimeoutUVD: time.Second * 60, TimeoutUVD: time.Second * 60,
}, },
}, },
})
if err != nil {
return nil, fmt.Errorf("failed to init webauthn object: %w", err)
} }
wa, _ := webauthn.New(webauthnConfig)
return &WebAuthnService{ return &WebAuthnService{
db: db, db: db,
webAuthn: wa, webAuthn: wa,
jwtService: jwtService, jwtService: jwtService,
auditLogService: auditLogService, auditLogService: auditLogService,
appConfigService: appConfigService, appConfigService: appConfigService,
} }, nil
} }
func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) (*model.PublicKeyCredentialCreationOptions, error) { func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) (*model.PublicKeyCredentialCreationOptions, error) {
@@ -70,8 +74,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
Find(&user, "id = ?", userID). Find(&user, "id = ?", userID).
Error Error
if err != nil { if err != nil {
tx.Rollback() return nil, fmt.Errorf("failed to load user: %w", err)
return nil, err
} }
options, session, err := s.webAuthn.BeginRegistration( options, session, err := s.webAuthn.BeginRegistration(
@@ -80,7 +83,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
) )
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
} }
sessionToStore := &model.WebauthnSession{ sessionToStore := &model.WebauthnSession{
@@ -94,12 +97,12 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
Create(&sessionToStore). Create(&sessionToStore).
Error Error
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to save WebAuthn session: %w", err)
} }
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return nil, err return nil, fmt.Errorf("failed to commit transaction: %w", err)
} }
return &model.PublicKeyCredentialCreationOptions{ return &model.PublicKeyCredentialCreationOptions{
@@ -115,13 +118,15 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
tx.Rollback() tx.Rollback()
}() }()
// Load & delete the session row
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
err := tx. err := tx.
WithContext(ctx). WithContext(ctx).
First(&storedSession, "id = ?", sessionID). Clauses(clause.Returning{}).
Delete(&storedSession, "id = ?", sessionID).
Error Error
if err != nil { if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", err)
} }
session := webauthn.SessionData{ session := webauthn.SessionData{
@@ -136,12 +141,12 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
Find(&user, "id = ?", userID). Find(&user, "id = ?", userID).
Error Error
if err != nil { if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, fmt.Errorf("failed to load user: %w", err)
} }
credential, err := s.webAuthn.FinishRegistration(&user, session, r) credential, err := s.webAuthn.FinishRegistration(&user, session, r)
if err != nil { if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, fmt.Errorf("failed to finish WebAuthn registration: %w", err)
} }
// Determine passkey name using AAGUID and User-Agent // Determine passkey name using AAGUID and User-Agent
@@ -162,12 +167,12 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
Create(&credentialToStore). Create(&credentialToStore).
Error Error
if err != nil { if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, fmt.Errorf("failed to store WebAuthn credential: %w", err)
} }
err = tx.Commit().Error err = tx.Commit().Error
if err != nil { if err != nil {
return model.WebauthnCredential{}, err return model.WebauthnCredential{}, fmt.Errorf("failed to commit transaction: %w", err)
} }
return credentialToStore, nil return credentialToStore, nil
@@ -216,13 +221,15 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
tx.Rollback() tx.Rollback()
}() }()
// Load & delete the session row
var storedSession model.WebauthnSession var storedSession model.WebauthnSession
err := tx. err := tx.
WithContext(ctx). WithContext(ctx).
First(&storedSession, "id = ?", sessionID). Clauses(clause.Returning{}).
Delete(&storedSession, "id = ?", sessionID).
Error Error
if err != nil { if err != nil {
return model.User{}, "", err return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", err)
} }
session := webauthn.SessionData{ session := webauthn.SessionData{
@@ -329,3 +336,136 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti
func (s *WebAuthnService) updateWebAuthnConfig() { func (s *WebAuthnService) updateWebAuthnConfig() {
s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
} }
func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
token, err := s.jwtService.VerifyAccessToken(accessToken)
if err != nil {
return "", fmt.Errorf("invalid access token: %w", err)
}
userID, ok := token.Subject()
if !ok {
return "", fmt.Errorf("access token does not contain user ID")
}
// Check if token is issued less than a minute ago
tokenExpiration, ok := token.IssuedAt()
if !ok || time.Since(tokenExpiration) > time.Minute {
return "", &common.ReauthenticationRequiredError{}
}
var user model.User
err = tx.
WithContext(ctx).
First(&user, "id = ?", userID).
Error
if err != nil {
return "", fmt.Errorf("failed to load user: %w", err)
}
reauthToken, err := s.createReauthenticationToken(ctx, tx, user.ID)
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err
}
return reauthToken, nil
}
func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Retrieve and delete the session
var storedSession model.WebauthnSession
err := tx.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&storedSession, "id = ? AND expires_at > ?", sessionID, datatype.DateTime(time.Now())).
Error
if err != nil {
return "", fmt.Errorf("failed to load WebAuthn session: %w", err)
}
session := webauthn.SessionData{
Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(),
}
// Validate the credential assertion
var user *model.User
_, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
innerErr := tx.
WithContext(ctx).
Preload("Credentials").
First(&user, "id = ?", string(userHandle)).
Error
if innerErr != nil {
return nil, innerErr
}
return user, nil
}, session, credentialAssertionData)
if err != nil || user == nil {
return "", err
}
// Create reauthentication token
token, err := s.createReauthenticationToken(ctx, tx, user.ID)
if err != nil {
return "", err
}
err = tx.Commit().Error
if err != nil {
return "", err
}
return token, nil
}
func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error {
hashedToken := utils.CreateSha256Hash(token)
result := tx.WithContext(ctx).
Clauses(clause.Returning{}).
Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return &common.ReauthenticationRequiredError{}
}
return nil
}
func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) {
token, err := utils.GenerateRandomAlphanumericString(32)
if err != nil {
return "", err
}
reauthToken := model.ReauthenticationToken{
Token: utils.CreateSha256Hash(token),
ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)),
UserID: userID,
}
err = tx.WithContext(ctx).Create(&reauthToken).Error
if err != nil {
return "", err
}
return token, nil
}

View File

@@ -4,7 +4,7 @@ import (
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"log" "log/slog"
"sync" "sync"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
@@ -57,12 +57,13 @@ func loadAAGUIDsFromFile() {
// Read from embedded file system // Read from embedded file system
data, err := resources.FS.ReadFile("aaguids.json") data, err := resources.FS.ReadFile("aaguids.json")
if err != nil { if err != nil {
log.Printf("Error reading embedded AAGUID file: %v", err) slog.Error("Error reading embedded AAGUID file", slog.Any("error", err))
return return
} }
if err := json.Unmarshal(data, &aaguidMap); err != nil { err = json.Unmarshal(data, &aaguidMap)
log.Printf("Error unmarshalling AAGUID data: %v", err) if err != nil {
slog.Error("Error unmarshalling AAGUID data", slog.Any("error", err))
return return
} }
} }

View File

@@ -1,12 +1,15 @@
package utils package utils
import ( import (
"crypto/rand"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"syscall"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
@@ -136,3 +139,41 @@ func FileExists(path string) (bool, error) {
} }
return !s.IsDir(), nil return !s.IsDir(), nil
} }
// IsWritableDir checks if a directory exists and is writable
func IsWritableDir(dir string) (bool, error) {
// Check if directory exists and it's actually a directory
info, err := os.Stat(dir)
if os.IsNotExist(err) {
return false, nil
} else if err != nil {
return false, fmt.Errorf("failed to stat '%s': %w", dir, err)
}
if !info.IsDir() {
return false, nil
}
// Generate a random suffix for the test file to avoid conflicts
randomBytes := make([]byte, 8)
_, err = io.ReadFull(rand.Reader, randomBytes)
if err != nil {
return false, fmt.Errorf("failed to generate random bytes: %w", err)
}
// Check if directory is writable by trying to create a temporary file
testFile := filepath.Join(dir, ".pocketid_test_write_"+hex.EncodeToString(randomBytes))
defer os.Remove(testFile)
file, err := os.Create(testFile)
if err != nil {
if os.IsPermission(err) || errors.Is(err, syscall.EROFS) {
return false, nil
}
return false, fmt.Errorf("failed to create test file: %w", err)
}
_ = file.Close()
return true, nil
}

View File

@@ -29,9 +29,9 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
pr, pw := io.Pipe() pr, pw := io.Pipe()
go func() { go func() {
err = imaging.Encode(pw, img, imaging.PNG) innerErr := imaging.Encode(pw, img, imaging.PNG)
if err != nil { if innerErr != nil {
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", err)) _ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", innerErr))
return return
} }
pw.Close() pw.Close()

View File

@@ -0,0 +1,42 @@
package utils
import (
"encoding/json"
"errors"
"time"
)
// JSONDuration is a type that allows marshalling/unmarshalling a Duration
type JSONDuration struct {
time.Duration
}
func (d JSONDuration) MarshalJSON() ([]byte, error) {
return json.Marshal(d.String())
}
func (d *JSONDuration) UnmarshalJSON(b []byte) error {
var v any
err := json.Unmarshal(b, &v)
if err != nil {
return err
}
switch value := v.(type) {
case float64:
// If the value is a number, interpret it as a number of seconds
d.Duration = time.Duration(value) * time.Second
return nil
case string:
if v == "" {
return nil
}
var err error
d.Duration, err = time.ParseDuration(value)
if err != nil {
return err
}
return nil
default:
return errors.New("invalid duration")
}
}

View File

@@ -0,0 +1,64 @@
package utils
import (
"encoding/json"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestJSONDuration_MarshalJSON(t *testing.T) {
tests := []struct {
duration time.Duration
want string
}{
{time.Minute + 30*time.Second, "1m30s"},
{0, "0s"},
}
for _, tc := range tests {
d := JSONDuration{Duration: tc.duration}
b, err := json.Marshal(d)
require.NoError(t, err)
assert.Equal(t, `"`+tc.want+`"`, string(b))
}
}
func TestJSONDuration_UnmarshalJSON_String(t *testing.T) {
var d JSONDuration
err := json.Unmarshal([]byte(`"2h15m5s"`), &d)
require.NoError(t, err)
want := 2*time.Hour + 15*time.Minute + 5*time.Second
assert.Equal(t, want, d.Duration)
}
func TestJSONDuration_UnmarshalJSON_NumberSeconds(t *testing.T) {
tests := []struct {
json string
want time.Duration
}{
{"0", 0},
{"1", 1 * time.Second},
{"2.25", 2 * time.Second}, // Milliseconds are truncated
}
for _, tc := range tests {
var d JSONDuration
err := json.Unmarshal([]byte(tc.json), &d)
require.NoError(t, err, "input: %s", tc.json)
assert.Equal(t, tc.want, d.Duration, "input: %s", tc.json)
}
}
func TestJSONDuration_UnmarshalJSON_Invalid(t *testing.T) {
cases := [][]byte{
[]byte(`true`),
[]byte(`{}`),
[]byte(`"not-a-duration"`),
}
for _, b := range cases {
var d JSONDuration
err := json.Unmarshal(b, &d)
require.Error(t, err, "input: %s", string(b))
}
}

View File

@@ -15,7 +15,6 @@ import (
"fmt" "fmt"
"hash" "hash"
"io" "io"
"os"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
@@ -47,26 +46,15 @@ func EncodeJWKBytes(key jwk.Key) ([]byte, error) {
// LoadKeyEncryptionKey loads the key encryption key for JWKs // LoadKeyEncryptionKey loads the key encryption key for JWKs
func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) { func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) {
// Try getting the key from the env var as string // If there's no key, return
kekInput := []byte(envConfig.EncryptionKey) if len(envConfig.EncryptionKey) == 0 {
// If there's nothing in the env, try loading from file
if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" {
kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile)
if err != nil {
return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err)
}
}
// If there's still no key, return
if len(kekInput) == 0 {
return nil, nil return nil, nil
} }
// We need a 256-bit key for encryption with AES-GCM-256 // We need a 256-bit key for encryption with AES-GCM-256
// We use HMAC with SHA3-256 here to derive the key from the one passed as input // We use HMAC with SHA3-256 here to derive the key from the one passed as input
// The key is tied to a specific instance of Pocket ID // The key is tied to a specific instance of Pocket ID
h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput) h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(envConfig.EncryptionKey))
fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek") fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek")
kek = h.Sum(nil) kek = h.Sum(nil)

View File

@@ -3,6 +3,7 @@ package utils
import ( import (
"reflect" "reflect"
"strconv" "strconv"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
@@ -35,9 +36,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn) sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable")) isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") { sort.Direction = NormalizeSortDirection(sort.Direction)
sort.Direction = "asc"
}
if sortFieldFound && isSortable { if sortFieldFound && isSortable {
columnName := CamelCaseToSnakeCase(sort.Column) columnName := CamelCaseToSnakeCase(sort.Column)
@@ -85,3 +84,16 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
ItemsPerPage: pageSize, ItemsPerPage: pageSize,
}, nil }, nil
} }
func NormalizeSortDirection(direction string) string {
d := strings.ToLower(strings.TrimSpace(direction))
if d != "asc" && d != "desc" {
return "asc"
}
return d
}
func IsValidSortDirection(direction string) bool {
d := strings.ToLower(strings.TrimSpace(direction))
return d == "asc" || d == "desc"
}

View File

@@ -2,7 +2,7 @@ package signals
import ( import (
"context" "context"
"log" "log/slog"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@@ -28,11 +28,11 @@ func SignalContext(parentCtx context.Context) context.Context {
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
go func() { go func() {
<-sigCh <-sigCh
log.Println("Received interrupt signal. Shutting down…") slog.Info("Received interrupt signal. Shutting down…")
cancel() cancel()
<-sigCh <-sigCh
log.Println("Received a second interrupt signal. Forcing an immediate shutdown.") slog.Warn("Received a second interrupt signal. Forcing an immediate shutdown.")
os.Exit(1) os.Exit(1)
}() }()

View File

@@ -0,0 +1,85 @@
package utils
import (
"context"
"errors"
"fmt"
"log/slog"
"slices"
)
// This file contains code adapted from https://github.com/samber/slog-multi
// Source: https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/pipe.go
// Copyright (C) 2023 Samuel Berthe
// License: MIT (https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/LICENSE)
// LogFanoutHandler is a slog.Handler that sends logs to multiple destinations
type LogFanoutHandler []slog.Handler
// Implements slog.Handler
func (h LogFanoutHandler) Enabled(ctx context.Context, l slog.Level) bool {
for i := range h {
if h[i].Enabled(ctx, l) {
return true
}
}
return false
}
// Implements slog.Handler
func (h LogFanoutHandler) Handle(ctx context.Context, r slog.Record) error {
errs := make([]error, 0)
for i := range h {
if h[i].Enabled(ctx, r.Level) {
err := try(func() error {
return h[i].Handle(ctx, r.Clone())
})
if err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
// Implements slog.Handler
func (h LogFanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
res := make(LogFanoutHandler, len(h))
for i, v := range h {
res[i] = v.WithAttrs(slices.Clone(attrs))
}
return res
}
// Implements slog.Handler
func (h LogFanoutHandler) WithGroup(name string) slog.Handler {
// https://cs.opensource.google/go/x/exp/+/46b07846:slog/handler.go;l=247
if name == "" {
return h
}
res := make(LogFanoutHandler, len(h))
for i, v := range h {
res[i] = v.WithGroup(name)
}
return res
}
func try(callback func() error) (err error) {
defer func() {
r := recover()
if r != nil {
if e, ok := r.(error); ok {
err = e
} else {
err = fmt.Errorf("unexpected error: %+v", r)
}
}
}()
err = callback()
return
}

View File

@@ -0,0 +1,51 @@
package sqlite
import (
"database/sql/driver"
"errors"
"fmt"
"strings"
sqlitelib "github.com/glebarez/go-sqlite"
"golang.org/x/text/unicode/norm"
)
func RegisterSqliteFunctions() {
// Register the `normalize(text, form)` function, which performs Unicode normalization on the text
// This is currently only used in migration functions
sqlitelib.MustRegisterDeterministicScalarFunction("normalize", 2, func(ctx *sqlitelib.FunctionContext, args []driver.Value) (driver.Value, error) {
if len(args) != 2 {
return nil, errors.New("normalize requires 2 arguments")
}
arg0, ok := args[0].(string)
if !ok {
return nil, fmt.Errorf("first argument for normalize is not a string: %T", args[0])
}
arg1, ok := args[1].(string)
if !ok {
return nil, fmt.Errorf("second argument for normalize is not a string: %T", args[1])
}
var form norm.Form
switch strings.ToLower(arg1) {
case "nfc":
form = norm.NFC
case "nfd":
form = norm.NFD
case "nfkc":
form = norm.NFKC
case "nfkd":
form = norm.NFKD
default:
return nil, fmt.Errorf("unsupported form: %s", arg1)
}
if len(arg0) == 0 {
return arg0, nil
}
return form.String(arg0), nil
})
}

View File

@@ -17,9 +17,14 @@ import (
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
func init() {
sqliteutil.RegisterSqliteFunctions()
}
// NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database. // NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database.
// Each database connection is unique for the test. // Each database connection is unique for the test.
// All migrations are automatically performed. // All migrations are automatically performed.

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
-- No-op

View File

@@ -0,0 +1,34 @@
-- Normalize (form NFC) all existing values in the database
DO $$
BEGIN
-- This function is available only if the server's encoding is UTF8
IF current_setting('server_encoding') = 'UTF8' THEN
UPDATE api_keys SET
name = normalize(name, NFC),
description = normalize(description, NFC);
UPDATE app_config_variables SET
"value" = normalize("value", NFC)
WHERE "key" = 'appName';
UPDATE custom_claims SET
"key" = normalize("key", NFC),
"value" = normalize("value", NFC);
UPDATE oidc_clients SET
name = normalize(name, NFC);
UPDATE users SET
username = normalize(username, NFC),
email = normalize(email, NFC),
first_name = normalize(first_name, NFC),
last_name = normalize(last_name, NFC);
UPDATE user_groups SET
friendly_name = normalize(friendly_name, NFC),
"name" = normalize("name", NFC);
ELSE
RAISE NOTICE 'Skipping normalization: server_encoding is %', current_setting('server_encoding');
END IF;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN launch_url;
ALTER TABLE user_authorized_oidc_clients DROP COLUMN last_used_at;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT;
ALTER TABLE user_authorized_oidc_clients ADD COLUMN last_used_at TIMESTAMPTZ NOT NULL DEFAULT current_timestamp;

View File

@@ -0,0 +1,2 @@
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
DROP TABLE IF EXISTS reauthentication_tokens;

View File

@@ -0,0 +1,11 @@
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE reauthentication_tokens (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
user_id uuid NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);

View File

@@ -0,0 +1,7 @@
ALTER TABLE public.audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey,
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users (id);
ALTER TABLE public.oidc_authorization_codes
DROP CONSTRAINT IF EXISTS oidc_authorization_codes_client_fk;

View File

@@ -0,0 +1,8 @@
ALTER TABLE public.audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey,
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users (id) ON DELETE CASCADE;
ALTER TABLE public.oidc_authorization_codes
ADD CONSTRAINT oidc_authorization_codes_client_fk
FOREIGN KEY (client_id) REFERENCES public.oidc_clients (id) ON DELETE CASCADE;

View File

@@ -0,0 +1 @@
-- No-op because strings can't be converted to UUIDs

View File

@@ -0,0 +1,57 @@
-- Drop foreign keys that reference oidc_clients(id)
ALTER TABLE oidc_authorization_codes
DROP CONSTRAINT IF EXISTS oidc_authorization_codes_client_fk;
ALTER TABLE user_authorized_oidc_clients
DROP CONSTRAINT IF EXISTS user_authorized_oidc_clients_client_id_fkey;
ALTER TABLE oidc_refresh_tokens
DROP CONSTRAINT IF EXISTS oidc_refresh_tokens_client_id_fkey;
ALTER TABLE oidc_device_codes
DROP CONSTRAINT IF EXISTS oidc_device_codes_client_id_fkey;
ALTER TABLE oidc_clients_allowed_user_groups
DROP CONSTRAINT IF EXISTS oidc_clients_allowed_user_groups_oidc_client_id_fkey;
-- Alter child columns to TEXT
ALTER TABLE oidc_authorization_codes
ALTER COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE user_authorized_oidc_clients
ALTER
COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE oidc_refresh_tokens
ALTER
COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE oidc_device_codes
ALTER
COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE oidc_clients_allowed_user_groups
ALTER
COLUMN oidc_client_id TYPE TEXT USING oidc_client_id::text;
-- Alter parent primary key column to TEXT
ALTER TABLE oidc_clients
ALTER
COLUMN id TYPE TEXT USING id::text;
-- Recreate foreign keys with the new type
ALTER TABLE oidc_authorization_codes
ADD CONSTRAINT oidc_authorization_codes_client_fk
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE user_authorized_oidc_clients
ADD CONSTRAINT user_authorized_oidc_clients_client_id_fkey
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE oidc_refresh_tokens
ADD CONSTRAINT oidc_refresh_tokens_client_id_fkey
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE oidc_device_codes
ADD CONSTRAINT oidc_device_codes_client_id_fkey
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE oidc_clients_allowed_user_groups
ADD CONSTRAINT oidc_clients_allowed_user_groups_oidc_client_id_fkey
FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;

View File

@@ -0,0 +1 @@
-- No-op

View File

@@ -0,0 +1,25 @@
-- Normalize (form NFC) all existing values in the database
UPDATE api_keys SET
name = normalize(name, 'nfc'),
description = normalize(description, 'nfc');
UPDATE app_config_variables SET
"value" = normalize("value", 'nfc')
WHERE "key" = 'appName';
UPDATE custom_claims SET
"key" = normalize("key", 'nfc'),
"value" = normalize("value", 'nfc');
UPDATE oidc_clients SET
name = normalize(name, 'nfc');
UPDATE users SET
username = normalize(username, 'nfc'),
email = normalize(email, 'nfc'),
first_name = normalize(first_name, 'nfc'),
last_name = normalize(last_name, 'nfc');
UPDATE user_groups SET
friendly_name = normalize(friendly_name, 'nfc'),
"name" = normalize("name", 'nfc');

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN launch_url;
ALTER TABLE user_authorized_oidc_clients DROP COLUMN created_at;

View File

@@ -0,0 +1,16 @@
ALTER TABLE oidc_clients ADD COLUMN launch_url TEXT;
CREATE TABLE user_authorized_oidc_clients_new
(
scope TEXT,
user_id TEXT,
client_id TEXT REFERENCES oidc_clients,
last_used_at DATETIME NOT NULL,
PRIMARY KEY (user_id, client_id)
);
INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
SELECT scope, user_id, client_id, unixepoch() FROM user_authorized_oidc_clients;
DROP TABLE user_authorized_oidc_clients;
ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;

View File

@@ -0,0 +1,3 @@
ALTER TABLE oidc_clients DROP COLUMN requires_reauthentication;
DROP INDEX IF EXISTS idx_reauthentication_tokens_token;
DROP TABLE IF EXISTS reauthentication_tokens;

View File

@@ -0,0 +1,11 @@
ALTER TABLE oidc_clients ADD COLUMN requires_reauthentication BOOLEAN NOT NULL DEFAULT FALSE;
CREATE TABLE reauthentication_tokens (
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
CREATE INDEX idx_reauthentication_tokens_token ON reauthentication_tokens(token);

View File

@@ -0,0 +1,177 @@
PRAGMA foreign_keys=OFF;
---------------------------
-- Delete all orphaned rows
---------------------------
UPDATE oidc_clients
SET created_by_id = NULL
WHERE created_by_id IS NOT NULL
AND created_by_id NOT IN (SELECT id FROM users);
DELETE FROM oidc_authorization_codes WHERE user_id NOT IN (SELECT id FROM users);
DELETE FROM one_time_access_tokens WHERE user_id NOT IN (SELECT id FROM users);
DELETE FROM webauthn_credentials WHERE user_id NOT IN (SELECT id FROM users);
DELETE FROM audit_logs WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users);
DELETE FROM api_keys WHERE user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users);
DELETE FROM oidc_refresh_tokens WHERE user_id NOT IN (SELECT id FROM users) OR client_id NOT IN (SELECT id FROM oidc_clients);
DELETE FROM oidc_device_codes WHERE (user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)) OR client_id NOT IN (SELECT id FROM oidc_clients);
DELETE FROM user_authorized_oidc_clients WHERE user_id NOT IN (SELECT id FROM users) OR client_id NOT IN (SELECT id FROM oidc_clients);
DELETE FROM user_groups_users WHERE user_id NOT IN (SELECT id FROM users) OR user_group_id NOT IN (SELECT id FROM user_groups);
DELETE FROM custom_claims WHERE (user_id IS NOT NULL AND user_id NOT IN (SELECT id FROM users)) OR (user_group_id IS NOT NULL AND user_group_id NOT IN (SELECT id FROM user_groups));
DELETE FROM oidc_clients_allowed_user_groups WHERE oidc_client_id NOT IN (SELECT id FROM oidc_clients) OR user_group_id NOT IN (SELECT id FROM user_groups);
DELETE FROM reauthentication_tokens WHERE user_id NOT IN (SELECT id FROM users);
---------------------------
-- Add missing foreign keys and edit cascade behavior where necessary
---------------------------
-- reauthentication_tokens: add missing FK user_id → users
CREATE TABLE reauthentication_tokens_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at INTEGER NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
INSERT INTO reauthentication_tokens_new (id, created_at, token, expires_at, user_id)
SELECT id, created_at, token, expires_at, user_id
FROM reauthentication_tokens;
DROP TABLE reauthentication_tokens;
ALTER TABLE reauthentication_tokens_new RENAME TO reauthentication_tokens;
CREATE INDEX idx_reauthentication_tokens_token
ON reauthentication_tokens (token);
-- oidc_authorization_codes: add FK client_id, user_id → CASCADE
CREATE TABLE oidc_authorization_codes_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
code TEXT NOT NULL UNIQUE,
scope TEXT NOT NULL,
nonce TEXT,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
code_challenge TEXT,
code_challenge_method_sha256 NUMERIC
);
INSERT INTO oidc_authorization_codes_new
(id, created_at, code, scope, nonce, expires_at, user_id, client_id, code_challenge, code_challenge_method_sha256)
SELECT id, created_at, code, scope, nonce, expires_at, user_id, client_id, code_challenge, code_challenge_method_sha256
FROM oidc_authorization_codes;
DROP TABLE oidc_authorization_codes;
ALTER TABLE oidc_authorization_codes_new RENAME TO oidc_authorization_codes;
-- user_authorized_oidc_clients: add FK user_id, cascade client_id
CREATE TABLE user_authorized_oidc_clients_new
(
scope TEXT,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE,
client_id TEXT NOT NULL REFERENCES oidc_clients ON DELETE CASCADE,
last_used_at DATETIME NOT NULL,
PRIMARY KEY (user_id, client_id)
);
INSERT INTO user_authorized_oidc_clients_new (scope, user_id, client_id, last_used_at)
SELECT scope, user_id, client_id, last_used_at
FROM user_authorized_oidc_clients;
DROP TABLE user_authorized_oidc_clients;
ALTER TABLE user_authorized_oidc_clients_new RENAME TO user_authorized_oidc_clients;
-- audit_logs: user_id → CASCADE
CREATE TABLE audit_logs_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
event TEXT NOT NULL,
ip_address TEXT,
user_agent TEXT NOT NULL,
data BLOB NOT NULL,
user_id TEXT REFERENCES users ON DELETE CASCADE,
country TEXT,
city TEXT
);
INSERT INTO audit_logs_new
(id, created_at, event, ip_address, user_agent, data, user_id, country, city)
SELECT id, created_at, event, ip_address, user_agent, data, user_id, country, city
FROM audit_logs;
DROP TABLE audit_logs;
ALTER TABLE audit_logs_new RENAME TO audit_logs;
CREATE INDEX idx_audit_logs_client_name ON audit_logs((json_extract(data, '$.clientName')));
CREATE INDEX idx_audit_logs_country ON audit_logs (country);
CREATE INDEX idx_audit_logs_created_at ON audit_logs (created_at);
CREATE INDEX idx_audit_logs_event ON audit_logs (event);
CREATE INDEX idx_audit_logs_user_agent ON audit_logs (user_agent);
CREATE INDEX idx_audit_logs_user_id ON audit_logs (user_id);
-- oidc_clients: created_by_id → SET NULL
CREATE TABLE oidc_clients_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
name TEXT,
secret TEXT,
callback_urls BLOB,
image_type TEXT,
created_by_id TEXT REFERENCES users ON DELETE SET NULL,
is_public BOOLEAN DEFAULT FALSE,
pkce_enabled BOOLEAN DEFAULT FALSE,
logout_callback_urls BLOB,
credentials TEXT,
launch_url TEXT,
requires_reauthentication BOOLEAN DEFAULT FALSE NOT NULL
);
INSERT INTO oidc_clients_new
(id, created_at, name, secret, callback_urls, image_type, created_by_id,
is_public, pkce_enabled, logout_callback_urls, credentials, launch_url, requires_reauthentication)
SELECT id, created_at, name, secret, callback_urls, image_type, created_by_id,
is_public, pkce_enabled, logout_callback_urls, credentials, launch_url, requires_reauthentication
FROM oidc_clients;
DROP TABLE oidc_clients;
ALTER TABLE oidc_clients_new RENAME TO oidc_clients;
-- one_time_access_tokens: user_id → CASCADE
CREATE TABLE one_time_access_tokens_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at DATETIME NOT NULL,
user_id TEXT NOT NULL REFERENCES users ON DELETE CASCADE
);
INSERT INTO one_time_access_tokens_new
(id, created_at, token, expires_at, user_id)
SELECT id, created_at, token, expires_at, user_id
FROM one_time_access_tokens;
DROP TABLE one_time_access_tokens;
ALTER TABLE one_time_access_tokens_new RENAME TO one_time_access_tokens;
-- webauthn_credentials: user_id → CASCADE
CREATE TABLE webauthn_credentials_new
(
id TEXT PRIMARY KEY,
created_at DATETIME NOT NULL,
name TEXT NOT NULL,
credential_id TEXT NOT NULL UNIQUE,
public_key BLOB NOT NULL,
attestation_type TEXT NOT NULL,
transport BLOB NOT NULL,
user_id TEXT REFERENCES users ON DELETE CASCADE,
backup_eligible BOOLEAN DEFAULT FALSE NOT NULL,
backup_state BOOLEAN DEFAULT FALSE NOT NULL
);
INSERT INTO webauthn_credentials_new
(id, created_at, name, credential_id, public_key, attestation_type,
transport, user_id, backup_eligible, backup_state)
SELECT id, created_at, name, credential_id, public_key, attestation_type,
transport, user_id, backup_eligible, backup_state
FROM webauthn_credentials;
DROP TABLE webauthn_credentials;
ALTER TABLE webauthn_credentials_new RENAME TO webauthn_credentials;
PRAGMA foreign_keys=ON;
PRAGMA foreign_key_check;

View File

@@ -23,7 +23,7 @@
"click_to_copy": "Kliknutím zkopírujete", "click_to_copy": "Kliknutím zkopírujete",
"something_went_wrong": "Něco se pokazilo", "something_went_wrong": "Něco se pokazilo",
"go_back_to_home": "Přejít zpět domů", "go_back_to_home": "Přejít zpět domů",
"dont_have_access_to_your_passkey": "Nemáte přístup k Vašemu přístupovému klíči?", "alternative_sign_in_methods": "Alternativní způsoby přihlášení",
"login_background": "Pozadí přihlašovací stránky", "login_background": "Pozadí přihlašovací stránky",
"logo": "Logo", "logo": "Logo",
"login_code": "Přihlašovací kód", "login_code": "Přihlašovací kód",
@@ -276,6 +276,8 @@
"public_clients_description": "Veřejní klienti nemají client secret a místo toho používají PKCE. Povolte to, pokud je váš klient SPA nebo mobilní aplikace.", "public_clients_description": "Veřejní klienti nemají client secret a místo toho používají PKCE. Povolte to, pokud je váš klient SPA nebo mobilní aplikace.",
"pkce": "PKCE", "pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Exchange je bezpečnostní funkce, která zabraňuje útokům CSRF a narušení autorizačních kódů.", "public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Exchange je bezpečnostní funkce, která zabraňuje útokům CSRF a narušení autorizačních kódů.",
"requires_reauthentication": "Vyžaduje opětovné ověření",
"requires_users_to_authenticate_again_on_each_authorization": "Vyžaduje, aby se uživatelé při každém autorizačním pokusu znovu ověřili, i když jsou již přihlášeni.",
"name_logo": "Logo {name}", "name_logo": "Logo {name}",
"change_logo": "Změnit logo", "change_logo": "Změnit logo",
"upload_logo": "Nahrát logo", "upload_logo": "Nahrát logo",
@@ -385,6 +387,12 @@
"number_of_times_token_can_be_used": "Kolikrát lze použít registrační token.", "number_of_times_token_can_be_used": "Kolikrát lze použít registrační token.",
"expires": "Vyprší", "expires": "Vyprší",
"signup": "Zaregistrovat se", "signup": "Zaregistrovat se",
"user_creation": "Vytvoření uživatele",
"configure_user_creation": "Spravujte nastavení vytváření uživatelů, včetně metod registrace a výchozích oprávnění pro nové uživatele.",
"user_creation_groups_description": "Při registraci automaticky přiřaďte tyto skupiny novým uživatelům.",
"user_creation_claims_description": "Při registraci automaticky přiřaďte tyto vlastní nároky novým uživatelům.",
"user_creation_updated_successfully": "Nastavení pro vytváření uživatelů bylo úspěšně aktualizováno.",
"signup_disabled_description": "Registrace uživatelů jsou kompletně zakázány. Nové uživatelské účty mohou vytvářet pouze správci.",
"signup_requires_valid_token": "Pro vytvoření účtu je vyžadován platný registrační token", "signup_requires_valid_token": "Pro vytvoření účtu je vyžadován platný registrační token",
"validating_signup_token": "Ověřování registračního tokenu", "validating_signup_token": "Ověřování registračního tokenu",
"go_to_login": "Přejít na přihlášení", "go_to_login": "Přejít na přihlášení",
@@ -396,7 +404,7 @@
"skip_for_now": "Prozatím přeskočit", "skip_for_now": "Prozatím přeskočit",
"account_created": "Účet vytvořen", "account_created": "Účet vytvořen",
"enable_user_signups": "Povolit registraci uživatelů", "enable_user_signups": "Povolit registraci uživatelů",
"enable_user_signups_description": "Určuje, zda by měla být funkce registrace uživatele povolena.", "enable_user_signups_description": "Rozhodněte, jak se uživatelé mohou registrovat pro nové účty v Pocket ID.",
"user_signups_are_disabled": "Registrace uživatelů jsou v současné době zakázány", "user_signups_are_disabled": "Registrace uživatelů jsou v současné době zakázány",
"create_signup_token": "Vytvořit registrační token", "create_signup_token": "Vytvořit registrační token",
"view_active_signup_tokens": "Zobrazit aktivní registrační tokeny", "view_active_signup_tokens": "Zobrazit aktivní registrační tokeny",
@@ -412,12 +420,25 @@
"loading": "Načítání", "loading": "Načítání",
"delete_signup_token": "Odstranit registrační token", "delete_signup_token": "Odstranit registrační token",
"are_you_sure_you_want_to_delete_this_signup_token": "Jste si jisti, že chcete odstranit tento registrační token? Tuto akci nelze vrátit zpět.", "are_you_sure_you_want_to_delete_this_signup_token": "Jste si jisti, že chcete odstranit tento registrační token? Tuto akci nelze vrátit zpět.",
"signup_disabled_description": "Registrace uživatelů jsou kompletně zakázány. Nové uživatelské účty mohou vytvářet pouze správci.",
"signup_with_token": "Zaregistrovat se s tokenem", "signup_with_token": "Zaregistrovat se s tokenem",
"signup_with_token_description": "Uživatelé se mohou zaregistrovat pouze pomocí platného registračního tokenu který byl vytvořen správcem.", "signup_with_token_description": "Uživatelé se mohou zaregistrovat pouze pomocí platného registračního tokenu který byl vytvořen správcem.",
"signup_open": "Otevřená registrace", "signup_open": "Otevřená registrace",
"signup_open_description": "Kdokoli si může vytvořit nový účet bez omezení.", "signup_open_description": "Kdokoli si může vytvořit nový účet bez omezení.",
"of": "z", "of": "z",
"skip_passkey_setup": "Přeskočit nastavení přístupového klíče", "skip_passkey_setup": "Přeskočit nastavení přístupového klíče",
"skip_passkey_setup_description": "Je důrazně doporučeno nastavit přístupový klíč, bez něho se nebudete moci přihlásit, jakmile aktuální relace vyprší." "skip_passkey_setup_description": "Je důrazně doporučeno nastavit přístupový klíč, bez něho se nebudete moci přihlásit, jakmile aktuální relace vyprší.",
"my_apps": "Moje aplikace",
"no_apps_available": "Žádné aplikace nejsou k dispozici",
"contact_your_administrator_for_app_access": "Kontaktujte svého správce, abyste získali přístup k aplikacím.",
"launch": "Spuštění",
"client_launch_url": "URL spuštění klienta",
"client_launch_url_description": "URL adresa, která se otevře, když uživatel spustí aplikaci ze stránky Moje aplikace.",
"client_name_description": "Název klienta, který se zobrazuje v uživatelském rozhraní Pocket ID.",
"revoke_access": "Zrušit přístup",
"revoke_access_description": "Zrušit přístup k <b>{clientName}</b>. <b>{clientName}</b> už nebude mít přístup k informacím o vašem účtu.",
"revoke_access_successful": "Přístup k {clientName} byl úspěšně zrušen.",
"last_signed_in_ago": "Naposledy přihlášen {time} před",
"invalid_client_id": "ID klienta může obsahovat pouze písmena, číslice, podtržítka a pomlčky.",
"custom_client_id_description": "Nastavte vlastní ID klienta, pokud to vyžaduje vaše aplikace. V opačném případě pole nechte prázdné, aby bylo vygenerováno náhodné ID.",
"generated": "Vygenerováno"
} }

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