Compare commits

..

68 Commits

Author SHA1 Message Date
Alessandro (Ale) Segala
419b7613ee Update example .env to use database for keys storage
Given recent conversations on how the database will be the preferred (or sole) method in a future release, start by updating the example env
2025-11-10 07:17:38 -08:00
Elias Schneider
42b872d6b2 chore(translations): update translations via Crowdin (#1085) 2025-11-10 14:46:48 +01:00
Elias Schneider
bfd71d090c feat: add support for S3 storage backend (#1080)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-11-10 09:02:25 +00:00
Kyle Mendell
d5e0cfd4a6 feat: light/dark/system mode switcher (#1081) 2025-11-09 13:28:58 -06:00
Kyle Mendell
9981304b4b chore(deps): update pnpm to 10.20 (#1082) 2025-11-08 13:09:05 -06:00
Elias Schneider
5cf73e9309 fix: use quoted-printable encoding for mails to prevent line limitation 2025-11-08 17:34:43 +01:00
Elias Schneider
f125cf0dad release: 1.15.0 2025-11-06 15:49:39 +01:00
Elias Schneider
6a038fcf9a fix: remove redundant indexes in Postgres 2025-11-06 15:02:39 +01:00
Elias Schneider
76e0192cee fix: disabled property gets ignored when creating an user 2025-11-06 12:28:07 +01:00
Elias Schneider
3ebf94dd84 chore(translations): update translations via Crowdin (#1059) 2025-11-05 11:28:14 +01:00
dai
7ec57437ac fix: replace %lang% placeholder in html lang (#1071)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-11-04 13:01:12 +00:00
Elias Schneider
ed2c7b2303 feat: add ability to set default profile picture (#1061)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-11-04 13:40:00 +01:00
Elias Schneider
e03270eb9d fix: sorting by PKCE and re-auth of OIDC clients 2025-11-04 13:27:05 +01:00
Elias Schneider
d683d18d91 chore: add support for OpenBSD binaries 2025-11-01 16:26:43 +01:00
Elias Schneider
f184120890 feat: open edit page on table row click 2025-10-29 10:46:03 +01:00
Elias Schneider
04d8500910 release: 1.14.2 2025-10-29 09:25:28 +01:00
Mufeed Ali
93639dddb2 fix: dark oidc client icons not saved on client creation (#1057) 2025-10-28 12:35:56 +01:00
Elias Schneider
a190529117 chore(translations): add Turkish language files 2025-10-28 09:31:32 +01:00
Elias Schneider
73392b5837 release: 1.14.1 2025-10-27 14:07:34 +01:00
Elias Schneider
65616f65e5 fix: ignore trailing slashes in APP_URL 2025-10-27 10:48:27 +01:00
Elias Schneider
98a99fbb0a chore(translations): update translations via Crowdin (#1048) 2025-10-27 09:48:39 +01:00
Quentin L'Hours
3f3b6b88fd fix: use credProps to save passkey on firefox android (#1055) 2025-10-27 09:48:24 +01:00
Mufeed Ali
8f98d8c0b4 fix: Prevent blinding FOUC in dark mode (#1054) 2025-10-26 20:40:25 +01:00
Elias Schneider
c9308472a9 release: 1.14.0 2025-10-24 13:47:58 +02:00
Elias Schneider
6362ff9861 chore: upgrade dependencies 2025-10-24 12:18:38 +02:00
Elias Schneider
10d640385f fix: prevent page flickering on redirection based on auth state 2025-10-24 12:12:42 +02:00
Elias Schneider
47927d1574 fix: make pkce requirement visible in the oidc form if client is public 2025-10-24 10:57:44 +02:00
Elias Schneider
b356cef766 fix: only animate login background on initial page load 2025-10-24 10:53:51 +02:00
Elias Schneider
9fc45930a8 chore(translations): update translations via Crowdin (#1033) 2025-10-24 09:58:00 +02:00
Kyle Mendell
028d1c858e feat: add support for dark mode oidc client icons (#1039)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-10-24 09:57:12 +02:00
Alessandro (Ale) Segala
eb3963d0fc fix: use constant time comparisons when validating PKCE challenges (#1047) 2025-10-24 08:30:50 +02:00
dependabot[bot]
35d913f905 chore(deps-dev): bump vite from 7.0.7 to 7.0.8 in the npm_and_yarn group across 1 directory (#1042)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-21 18:04:28 -05:00
github-actions[bot]
32485f4c7c chore: update AAGUIDs (#1041)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-10-20 08:43:27 +02:00
Elias Schneider
ceb38b0825 chore(translations): update translations via Crowdin (#1025) 2025-10-16 08:28:13 +02:00
dependabot[bot]
c0b6ede5be chore(deps): bump sveltekit-superforms from 2.27.1 to 2.27.4 in the npm_and_yarn group across 1 directory (#1031)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-16 08:27:52 +02:00
Elias Schneider
c20e93b55c feat: add various improvements to the table component (#961)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-10-13 09:12:55 +00:00
Elias Schneider
24ca6a106d chore(translations): update translations via Crowdin (#1014) 2025-10-12 09:58:22 -05:00
Elias Schneider
9f0aa55be6 fix: ignore trailing slash in URL 2025-10-09 20:27:15 +02:00
Kyle Mendell
068fcc65a6 chore(translations): add Japanese files 2025-10-07 18:29:13 -05:00
Elias Schneider
f2dfb3da5d release: 1.13.1 2025-10-07 08:21:41 +02:00
Elias Schneider
cbf0e3117d fix: mark any callback url as valid if they contain a wildcard (#1006) 2025-10-07 08:18:53 +02:00
CzBiX
694f266dea fix: uploading a client logo with an URL fails (#1008) 2025-10-06 10:37:43 -05:00
Kyle Mendell
29fc185376 chore: cleanup root of repo, update workflow actions (#1003)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-10-05 14:49:06 -05:00
Elias Schneider
781be37416 release: 1.13.0 2025-10-05 17:30:47 +02:00
Elias Schneider
b1f97e05a1 chore(translations): update translations via Crowdin (#999) 2025-10-05 17:30:08 +02:00
Elias Schneider
2c74865173 feat: add link to API docs on API key page 2025-10-04 23:45:37 +02:00
Elias Schneider
ad8a90c839 fix: uploading a client logo with an URL fails if folder doesn't exist 2025-10-04 23:44:37 +02:00
Elias Schneider
f9839a978c release: 1.12.0 2025-10-03 11:59:38 +02:00
Elias Schneider
b81de45166 fix: date locale can't be loaded if locale is en 2025-10-03 11:54:07 +02:00
Elias Schneider
22f4254932 fix: allow any image source but disallow base64 2025-10-03 11:50:39 +02:00
Elias Schneider
507f9490fa feat: add the ability to make email optional (#994) 2025-10-03 11:24:53 +02:00
Elias Schneider
043cce615d feat: add required indicator for required inputs (#993) 2025-10-01 13:44:17 +02:00
Elias Schneider
69e2083722 chore(translations): update translations via Crowdin (#992) 2025-09-30 23:04:54 -05:00
Elias Schneider
d47b20326f fix: improve back button handling on auth pages 2025-09-30 14:44:08 +02:00
Elias Schneider
fc9939d1f1 fix: prevent endless effect loop in login wrapper 2025-09-30 14:06:13 +02:00
Elias Schneider
2c1c67b5e4 fix: include port in OIDC client details 2025-09-30 12:18:44 +02:00
Elias Schneider
d010be4c88 feat: hide alternative sign in methods page if email login disabled 2025-09-30 12:15:08 +02:00
Elias Schneider
01db8c0a46 fix: make logo and oidc client images sizes consistent 2025-09-30 12:12:37 +02:00
Alessandro (Ale) Segala
fe5917d96d fix: tokens issued with refresh token flow don't contain groups (#989)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-30 09:44:38 +00:00
Elias Schneider
4f0b434c54 chore(translations): update translations via Crowdin (#973) 2025-09-30 11:39:47 +02:00
Kyle Mendell
6bdf5fa37a feat: support for url based icons (#840)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-29 15:07:55 +00:00
Caian Benedicto
47bd5ba1ba fix: remove previous socket file to prevent bind error (#979) 2025-09-24 10:10:05 +00:00
Elias Schneider
b746ac0835 chore(email): remove unnecessary logo fallback 2025-09-24 12:08:24 +02:00
Elias Schneider
79989fb176 fix(email): display login location correctly if country or city is not present 2025-09-24 12:01:20 +02:00
Clément Contini
ecc7e224e9 fix: show only country in audit log location if no city instead of Unknown (#977) 2025-09-23 18:41:54 -05:00
Alessandro (Ale) Segala
549d219f44 fix(unit-tests): do not use cache=shared for in-memory SQLite (#971) 2025-09-21 19:32:41 -05:00
github-actions[bot]
ffe18db2fb chore: update AAGUIDs (#972)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-21 19:31:44 -05:00
Elias Schneider
e8b172f1c3 chore(release notes): fix whitespace after commit message 2025-09-20 22:54:55 +02:00
236 changed files with 8558 additions and 5378 deletions

View File

@@ -4,3 +4,11 @@ TRUST_PROXY=false
MAXMIND_LICENSE_KEY= MAXMIND_LICENSE_KEY=
PUID=1000 PUID=1000
PGID=1000 PGID=1000
KEYS_STORAGE=database
# Encryption key used to protect sensitive data
# Can be generated with `openssl rand -base64 32`
# See https://pocket-id.org/docs/configuration/environment-variables#encryption-keys
ENCRYPTION_KEY=
# Alternatively, point to a file containing the key (could be a Docker secret)
#ENCRYPTION_KEY_FILE=

View File

@@ -24,10 +24,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Set up Go - name: Set up Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
with: with:
go-version-file: backend/go.mod go-version-file: backend/go.mod

View File

@@ -19,22 +19,20 @@ jobs:
attestations: write attestations: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v5 uses: actions/setup-go@v6
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
@@ -74,7 +72,7 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next tags: ${{ env.DOCKER_IMAGE_NAME }}:next
file: Dockerfile-prebuilt file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless) - name: Build and push container image (distroless)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
id: container-build-push-distroless id: container-build-push-distroless
@@ -83,16 +81,16 @@ jobs:
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
push: true push: true
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
file: Dockerfile-distroless file: docker/Dockerfile-distroless
- 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

@@ -3,15 +3,15 @@ on:
push: push:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- 'docs/**' - "docs/**"
- '**.md' - "**.md"
- '.github/**' - ".github/**"
pull_request: pull_request:
branches: [main] branches: [main]
paths-ignore: paths-ignore:
- 'docs/**' - "docs/**"
- '**.md' - "**.md"
- '.github/**' - ".github/**"
jobs: jobs:
build: build:
@@ -22,7 +22,7 @@ jobs:
actions: write actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -30,6 +30,8 @@ jobs:
- name: Build and export - name: Build and export
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
context: .
file: docker/Dockerfile
push: false push: false
load: false load: false
tags: pocket-id:test tags: pocket-id:test
@@ -55,18 +57,18 @@ jobs:
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
db: [sqlite, postgres] db: [sqlite, postgres, sqlite-s3]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4 - name: Setup Node.js
uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v3 uses: actions/cache@v3
@@ -74,6 +76,7 @@ jobs:
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Cache PostgreSQL Docker image - name: Cache PostgreSQL Docker image
if: matrix.db == 'postgres' if: matrix.db == 'postgres'
uses: actions/cache@v3 uses: actions/cache@v3
@@ -88,9 +91,10 @@ jobs:
docker pull postgres:17 docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image from cache - name: Load PostgreSQL image
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true' if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar 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
@@ -104,9 +108,28 @@ jobs:
docker pull nitnelave/lldap:stable docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image from cache - name: Load LLDAP image
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: Cache Localstack S3 Docker image
if: matrix.db == 'sqlite-s3'
uses: actions/cache@v3
id: s3-cache
with:
path: /tmp/localstack-s3-image.tar
key: localstack-s3-latest-${{ runner.os }}
- name: Pull and save Localstack S3 image
if: matrix.db == 'sqlite-s3' && steps.s3-cache.outputs.cache-hit != 'true'
run: |
docker pull localstack/localstack:s3-latest
docker save localstack/localstack:s3-latest > /tmp/localstack-s3-image.tar
- name: Load Localstack S3 image
if: matrix.db == 'sqlite-s3' && steps.s3-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/localstack-s3-image.tar
- name: Download Docker image artifact - name: Download Docker image artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -123,6 +146,7 @@ jobs:
working-directory: ./tests working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container (sqlite) with LDAP - name: Run Docker Container (sqlite) with LDAP
if: matrix.db == 'sqlite' if: matrix.db == 'sqlite'
working-directory: ./tests/setup working-directory: ./tests/setup
@@ -137,6 +161,13 @@ jobs:
docker compose -f docker-compose-postgres.yml up -d docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log & docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Docker Container (sqlite-s3) with LDAP + S3
if: matrix.db == 'sqlite-s3'
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-s3.yml up -d
docker compose -f docker-compose-s3.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests - name: Run Playwright tests
working-directory: ./tests working-directory: ./tests
run: pnpm exec playwright test run: pnpm exec playwright test

View File

@@ -3,7 +3,7 @@ name: Release
on: on:
push: push:
tags: tags:
- 'v*.*.*' - "v*.*.*"
jobs: jobs:
build: build:
@@ -19,14 +19,12 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: 'pnpm' - uses: actions/setup-go@v6
cache-dependency-path: pnpm-lock.yaml
- 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
@@ -81,7 +79,7 @@ jobs:
push: true push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
file: Dockerfile-prebuilt file: docker/Dockerfile-prebuilt
- name: Build and push container image (distroless) - name: Build and push container image (distroless)
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
id: container-build-push-distroless id: container-build-push-distroless
@@ -91,21 +89,21 @@ jobs:
push: true push: true
tags: ${{ steps.meta-distroless.outputs.tags }} tags: ${{ steps.meta-distroless.outputs.tags }}
labels: ${{ steps.meta-distroless.outputs.labels }} labels: ${{ steps.meta-distroless.outputs.labels }}
file: Dockerfile-distroless file: docker/Dockerfile-distroless
- 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
@@ -122,6 +120,6 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- 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:
@@ -34,17 +34,15 @@ jobs:
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v5
with: with:
node-version: 22 node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies - name: Install dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile run: pnpm --filter pocket-id-frontend install --frozen-lockfile

View File

@@ -16,8 +16,8 @@ jobs:
actions: write actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- uses: actions/setup-go@v5 - uses: actions/setup-go@v6
with: with:
go-version-file: "backend/go.mod" go-version-file: "backend/go.mod"
cache-dependency-path: "backend/go.sum" cache-dependency-path: "backend/go.sum"

View File

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

5
.gitignore vendored
View File

@@ -1,8 +1,12 @@
# JetBrains # JetBrains
**/.idea **/.idea
# Node
node_modules node_modules
# PNPM
.pnpm-store/
# Output # Output
.output .output
.vercel .vercel
@@ -21,6 +25,7 @@ Thumbs.db
.env.* .env.*
!.env.development-example !.env.development-example
!.env.test !.env.test
!.env.example
# Vite # Vite
vite.config.js.timestamp-* vite.config.js.timestamp-*

View File

@@ -1 +1 @@
1.11.2 1.15.0

View File

@@ -1,16 +1,137 @@
## v1.15.0
### Bug Fixes
- sorting by PKCE and re-auth of OIDC clients ([e03270e](https://github.com/pocket-id/pocket-id/commit/e03270eb9d474735ff4a1b4d8c90f1857b8cd52b) by @stonith404)
- replace %lang% placeholder in html lang ([#1071](https://github.com/pocket-id/pocket-id/pull/1071) by @daimond113)
- disabled property gets ignored when creating an user ([76e0192](https://github.com/pocket-id/pocket-id/commit/76e0192ceec339b6ddb4ad3424057d2bb48fae8f) by @stonith404)
- remove redundant indexes in Postgres ([6a038fc](https://github.com/pocket-id/pocket-id/commit/6a038fcf9afabbf00c45e42071e9bbe62ecab403) by @stonith404)
### Features
- open edit page on table row click ([f184120](https://github.com/pocket-id/pocket-id/commit/f184120890c32f1e75a918c171084878a10e8b42) by @stonith404)
- add ability to set default profile picture ([#1061](https://github.com/pocket-id/pocket-id/pull/1061) by @stonith404)
### Other
- add support for OpenBSD binaries ([d683d18](https://github.com/pocket-id/pocket-id/commit/d683d18d9109ca2850e278b78f7bf3e5aca1d34d) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.14.2...v1.15.0
## v1.14.2
### Bug Fixes
- dark oidc client icons not saved on client creation ([#1057](https://github.com/pocket-id/pocket-id/pull/1057) by @mufeedali)
### Other
- add Turkish language files ([a190529](https://github.com/pocket-id/pocket-id/commit/a190529117fe20b5b836d452b382da69abba9458) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.14.1...v1.14.2
## v1.14.1
### Bug Fixes
- Prevent blinding FOUC in dark mode ([#1054](https://github.com/pocket-id/pocket-id/pull/1054) by @mufeedali)
- use credProps to save passkey on firefox android ([#1055](https://github.com/pocket-id/pocket-id/pull/1055) by @lhoursquentin)
- ignore trailing slashes in `APP_URL` ([65616f6](https://github.com/pocket-id/pocket-id/commit/65616f65e53f3e62d18a8209929e68ddc8d2b9b8) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.14.0...v1.14.1
## v1.14.0
### Bug Fixes
- ignore trailing slash in URL ([9f0aa55](https://github.com/pocket-id/pocket-id/commit/9f0aa55be67b7a09810569250563bb388b40590a) by @stonith404)
- use constant time comparisons when validating PKCE challenges ([#1047](https://github.com/pocket-id/pocket-id/pull/1047) by @ItalyPaleAle)
- only animate login background on initial page load ([b356cef](https://github.com/pocket-id/pocket-id/commit/b356cef766697c621157235ae1d2743f3fe6720d) by @stonith404)
- make pkce requirement visible in the oidc form if client is public ([47927d1](https://github.com/pocket-id/pocket-id/commit/47927d157470daa5b5a5b30e61a2ba69110eeff9) by @stonith404)
- prevent page flickering on redirection based on auth state ([10d6403](https://github.com/pocket-id/pocket-id/commit/10d640385ff2078299a07f05e5ca3f0d392eecf7) by @stonith404)
### Features
- add various improvements to the table component ([#961](https://github.com/pocket-id/pocket-id/pull/961) by @stonith404)
- add support for dark mode oidc client icons ([#1039](https://github.com/pocket-id/pocket-id/pull/1039) by @kmendell)
### Other
- add Japanese files ([068fcc6](https://github.com/pocket-id/pocket-id/commit/068fcc65a62c76f55c9636f830fc769bd59220c4) by @kmendell)
- bump sveltekit-superforms from 2.27.1 to 2.27.4 in the npm_and_yarn group across 1 directory ([#1031](https://github.com/pocket-id/pocket-id/pull/1031) by @dependabot[bot])
- update AAGUIDs ([#1041](https://github.com/pocket-id/pocket-id/pull/1041) by @github-actions[bot])
- bump vite from 7.0.7 to 7.0.8 in the npm_and_yarn group across 1 directory ([#1042](https://github.com/pocket-id/pocket-id/pull/1042) by @dependabot[bot])
- upgrade dependencies ([6362ff9](https://github.com/pocket-id/pocket-id/commit/6362ff986124d056cc07d214855f198eab9cb97d) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.13.1...v1.14.0
## v1.13.1
### Bug Fixes
- uploading a client logo with an URL fails ([#1008](https://github.com/pocket-id/pocket-id/pull/1008) by @CzBiX)
- mark any callback url as valid if they contain a wildcard ([#1006](https://github.com/pocket-id/pocket-id/pull/1006) by @stonith404)
### Other
- cleanup root of repo, update workflow actions ([#1003](https://github.com/pocket-id/pocket-id/pull/1003) by @kmendell)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.13.0...v1.13.1
## v1.13.0
### Bug Fixes
- uploading a client logo with an URL fails if folder doesn't exist ([ad8a90c](https://github.com/pocket-id/pocket-id/commit/ad8a90c839cc79b542b60ae66c7eb9254fa5f3e4) by @stonith404)
### Features
- add link to API docs on API key page ([2c74865](https://github.com/pocket-id/pocket-id/commit/2c74865173344766bd43ffd6ae6d93d564de47c7) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.12.0...v1.13.0
## v1.12.0
### Bug Fixes
- do not use cache=shared for in-memory SQLite ([#971](https://github.com/pocket-id/pocket-id/pull/971) by @ItalyPaleAle)
- show only country in audit log location if no city instead of Unknown ([#977](https://github.com/pocket-id/pocket-id/pull/977) by @vilisseranen)
- display login location correctly if country or city is not present ([79989fb](https://github.com/pocket-id/pocket-id/commit/79989fb176273cef070dc52c338004b443364db8) by @stonith404)
- remove previous socket file to prevent bind error ([#979](https://github.com/pocket-id/pocket-id/pull/979) by @Caian)
- tokens issued with refresh token flow don't contain groups ([#989](https://github.com/pocket-id/pocket-id/pull/989) by @ItalyPaleAle)
- make logo and oidc client images sizes consistent ([01db8c0](https://github.com/pocket-id/pocket-id/commit/01db8c0a46b69a15a40951ba863e6bc08fa8e1f8) by @stonith404)
- include port in OIDC client details ([2c1c67b](https://github.com/pocket-id/pocket-id/commit/2c1c67b5e403b365204854c5eb222a68236f3ce0) by @stonith404)
- prevent endless effect loop in login wrapper ([fc9939d](https://github.com/pocket-id/pocket-id/commit/fc9939d1f1817c0b014cc54e6525b98762835295) by @stonith404)
- improve back button handling on auth pages ([d47b203](https://github.com/pocket-id/pocket-id/commit/d47b20326f96b6fff405fcc211719bf3068085ee) by @stonith404)
- allow any image source but disallow base64 ([22f4254](https://github.com/pocket-id/pocket-id/commit/22f42549323fde8b9eaeff682bfa4c7f27e05526) by @stonith404)
- date locale can't be loaded if locale is `en` ([b81de45](https://github.com/pocket-id/pocket-id/commit/b81de451668c425bfc5ca7cd6071fe2756b31594) by @stonith404)
### Features
- support for url based icons ([#840](https://github.com/pocket-id/pocket-id/pull/840) by @kmendell)
- hide alternative sign in methods page if email login disabled ([d010be4](https://github.com/pocket-id/pocket-id/commit/d010be4c8804153b4a7f55bd4ea1cedb0df471df) by @stonith404)
- add required indicator for required inputs ([#993](https://github.com/pocket-id/pocket-id/pull/993) by @stonith404)
- add the ability to make email optional ([#994](https://github.com/pocket-id/pocket-id/pull/994) by @stonith404)
### Other
- fix whitespace after commit message ([e8b172f](https://github.com/pocket-id/pocket-id/commit/e8b172f1c3df8eca8f463d7fa25a483b90a7e66c) by @stonith404)
- update AAGUIDs ([#972](https://github.com/pocket-id/pocket-id/pull/972) by @github-actions[bot])
- remove unnecessary logo fallback ([b746ac0](https://github.com/pocket-id/pocket-id/commit/b746ac0835da059e747a829df3a74e1eae79e107) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.11.2...v1.12.0
## v1.11.2 ## v1.11.2
### Bug Fixes ### Bug Fixes
* embedded paths not found on windows([c55143d](https://github.com/pocket-id/pocket-id/commit/c55143d8c995fcd604edcdd448c50669e8682e33) by @stonith404) - embedded paths not found on windows ([c55143d](https://github.com/pocket-id/pocket-id/commit/c55143d8c995fcd604edcdd448c50669e8682e33) by @stonith404)
* do not treat certain failures in app images bootstrap as fatal ([#966](https://github.com/pocket-id/pocket-id/pull/966) by @ItalyPaleAle) - do not treat certain failures in app images bootstrap as fatal ([#966](https://github.com/pocket-id/pocket-id/pull/966) by @ItalyPaleAle)
* decouple images from app config service ([#965](https://github.com/pocket-id/pocket-id/pull/965) by @stonith404) - decouple images from app config service ([#965](https://github.com/pocket-id/pocket-id/pull/965) by @stonith404)
### Other ### Other
* use git cliff for release notes([fde4e9b](https://github.com/pocket-id/pocket-id/commit/fde4e9b38a34331137a64ce328dad6faf9885808) by @stonith404) - use git cliff for release notes ([fde4e9b](https://github.com/pocket-id/pocket-id/commit/fde4e9b38a34331137a64ce328dad6faf9885808) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.11.1...v1.11.2 **Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.11.1...v1.11.2

View File

@@ -32,10 +32,6 @@ func init() {
panic(fmt.Errorf("failed to read index.html: %w", iErr)) panic(fmt.Errorf("failed to read index.html: %w", iErr))
} }
// Get the position of the first <script> tag
idx := bytes.Index(index, []byte(scriptTag))
// Create writeIndexFn, which adds the CSP tag to the script tag if needed
writeIndexFn = func(w io.Writer, nonce string) (err error) { writeIndexFn = func(w io.Writer, nonce string) (err error) {
// If there's no nonce, write the index as-is // If there's no nonce, write the index as-is
if nonce == "" { if nonce == "" {
@@ -43,23 +39,16 @@ func init() {
return err return err
} }
// We have a nonce, so first write the index until the <script> tag // Add nonce to all <script> tags
// Then we write the modified script tag // We replace "<script" with `<script nonce="..."` everywhere it appears
// Finally, the rest of the index modified := bytes.ReplaceAll(
_, err = w.Write(index[0:idx]) index,
if err != nil { []byte(scriptTag),
return err []byte(`<script nonce="`+nonce+`">`),
} )
_, err = w.Write([]byte(`<script nonce="` + nonce + `">`))
if err != nil {
return err
}
_, err = w.Write(index[(idx + len(scriptTag)):])
if err != nil {
return err
}
return nil _, err = w.Write(modified)
return err
} }
} }
@@ -75,6 +64,11 @@ func RegisterFrontend(router *gin.Engine) error {
router.NoRoute(func(c *gin.Context) { router.NoRoute(func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/") path := strings.TrimPrefix(c.Request.URL.Path, "/")
if strings.HasSuffix(path, "/") {
c.Redirect(http.StatusMovedPermanently, strings.TrimRight(c.Request.URL.String(), "/"))
return
}
if strings.HasPrefix(path, "api/") { if strings.HasPrefix(path, "api/") {
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return return
@@ -94,13 +88,9 @@ func RegisterFrontend(router *gin.Engine) error {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store") c.Header("Cache-Control", "no-store")
c.Status(http.StatusOK) c.Status(http.StatusOK)
if err := writeIndexFn(c.Writer, nonce); err != nil {
err = writeIndexFn(c.Writer, nonce)
if err != nil {
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err)) _ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
return
} }
return return
} }

View File

@@ -3,88 +3,107 @@ module github.com/pocket-id/pocket-id/backend
go 1.25 go 1.25
require ( require (
github.com/aws/aws-sdk-go-v2 v1.39.6
github.com/aws/aws-sdk-go-v2/config v1.31.17
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0
github.com/aws/smithy-go v1.23.2
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.3 github.com/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-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.21.3 github.com/emersion/go-smtp v0.24.0
github.com/fxamacker/cbor/v2 v2.9.0 github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-contrib/slog v1.1.0 github.com/gin-contrib/slog v1.1.0
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0 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.16.3 github.com/go-co-op/gocron/v2 v2.17.0
github.com/go-ldap/ldap/v3 v3.4.10 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.27.0 github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.11.2 github.com/go-webauthn/webauthn v0.14.0
github.com/golang-migrate/migrate/v4 v4.18.3 github.com/golang-migrate/migrate/v4 v4.19.0
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 github.com/lestrrat-go/httprc/v3 v3.0.1
github.com/lestrrat-go/jwx/v3 v3.0.10 github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/lmittmann/tint v1.1.2 github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0 github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 github.com/oschwald/maxminddb-golang/v2 v2.0.0
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 go.opentelemetry.io/contrib/exporters/autoexport v0.63.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.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.37.0 go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/log v0.13.0 go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.37.0 go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.35.0 go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.10.0 go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.35.0 go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.37.0 go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.43.0
golang.org/x/image v0.30.0 golang.org/x/image v0.32.0
golang.org/x/sync v0.16.0 golang.org/x/sync v0.17.0
golang.org/x/text v0.28.0 golang.org/x/text v0.30.0
golang.org/x/time v0.12.0 golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.30.1 gorm.io/gorm v1.31.0
) )
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/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic v1.14.0 // indirect github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // 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.2.1 // 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.9 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // 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.23 // indirect github.com/go-webauthn/x v0.1.25 // 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.3 // indirect github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect github.com/google/go-tpm v0.9.6 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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.5 // indirect github.com/jackc/pgx/v5 v5.7.6 // 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
@@ -93,57 +112,64 @@ require (
github.com/klauspost/cpuid/v2 v2.3.0 // 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.4 // indirect github.com/lestrrat-go/blackmagic v1.0.4 // indirect
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/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/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-sqlite3 v1.14.24 // indirect github.com/mattn/go-sqlite3 v1.14.32 // 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 v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // 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.23.2 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.62.0 // indirect github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.18.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.0 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect github.com/spf13/pflag v1.0.10 // 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.3.0 // indirect github.com/ugorji/go/codec v1.3.0 // indirect
github.com/valyala/fastjson v1.6.4 // 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.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.5.0 // indirect go.opentelemetry.io/proto/otlp v1.8.0 // indirect
go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/mod v0.29.0 // indirect
golang.org/x/oauth2 v0.27.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/oauth2 v0.32.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect golang.org/x/sys v0.37.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect golang.org/x/tools v0.38.0 // indirect
google.golang.org/grpc v1.71.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/protobuf v1.36.7 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.66.7 // indirect modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.38.2 // indirect modernc.org/sqlite v1.39.1 // indirect
) )

View File

@@ -4,74 +4,115 @@ github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU= github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 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.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/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.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/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.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM= github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo= github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
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/gift v1.2.1 h1:Y005a1X4Z7Uc+0gLpSAsKhWi4qLtsdEcMIbbdvdZ6pc=
github.com/disintegration/gift v1.2.1/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk= github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec/go.mod h1:K0KBFIr1gWu/C1Gp10nFAcAE4hsB7JxE6OgLijrJ8Sk=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v27.2.0+incompatible h1:Rk9nIVdfH3+Vz4cyI/uhbINhEZ/oLmc+CBXmH6fbNk4= github.com/docker/docker v28.3.3+incompatible h1:Dypm25kh4rmk49v1eiVbsAtpAsYURjYkaKubwuBdxEI=
github.com/docker/docker v27.2.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker v28.3.3+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY= github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6Wk=
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ= github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4 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.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4= github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY= github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= 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.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
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.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI= github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c= github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU= github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY= github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -83,50 +124,48 @@ 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.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc= github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0= github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI= github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E= github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
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/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 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.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.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.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.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.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 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-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ= github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE= github.com/google/go-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 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= 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.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.6/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=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 h1:e9Rjr40Z98/clHv5Yg79Is0NtosR5LXRvdr7o/6NwbA=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1/go.mod h1:tIxuGz/9mpox++sgp9fJjHO0+q1X9/UOWd798aAm22M=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
@@ -135,8 +174,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.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs= github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2 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=
@@ -177,12 +216,16 @@ 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.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA= github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE= github.com/lestrrat-go/httpcc v1.0.1 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 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc= github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q= github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw= github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk= github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU= github.com/lestrrat-go/option v1.0.1 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 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
@@ -193,8 +236,8 @@ 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/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.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws= github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
@@ -212,55 +255,61 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM= github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU= github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA= github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs= github.com/oschwald/maxminddb-golang/v2 v2.0.0 h1:Gyljxck1kHbBxDgLM++NfDWBqvu1pWWfT8XbosSo0bo=
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w= github.com/oschwald/maxminddb-golang/v2 v2.0.0/go.mod h1:gG4V88LsawPEqtbL1Veh1WRh+nVSYwXzJ1P5Fcn77g0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io= github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I= github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
github.com/prometheus/procfs v0.18.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.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/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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.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.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1 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.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
@@ -269,190 +318,139 @@ github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXV
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= 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= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g= go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A= go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 h1:/Rij/t18Y7rUayNg7Id6rPrEnHgorxYabm2E6wUdPP4=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc= go.opentelemetry.io/contrib/bridges/prometheus v0.63.0/go.mod h1:AdyDPn6pkbkt2w01n3BubRVk7xAsCRq1Yg1mpfyA/0E=
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI= go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 h1:NLnZybb9KkfMXPwZhd5diBYJoVxiO9Qa06dacEA7ySY=
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA= go.opentelemetry.io/contrib/exporters/autoexport v0.63.0/go.mod h1:OvRg7gm5WRSCtxzGSsrFHbDLToYlStHNZQ+iPNIyD6g=
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0/go.mod h1:fPl+qlrhRdRntIpPs9JoQ0iBKAsnH5VkgppU1f9kyF4= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0 h1:5kSIJ0y8ckZZKoDhZHdVtcyjVi6rXyAwyaR8mp4zLbg=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0 h1:jj/B7eX95/mOxim9g9laNZkOHKz/XCHG0G410SntRy4= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0/go.mod h1:i+fIMHvcSQtsIY82/xgiVWRklrNt/O6QriHLjzGeY+s=
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.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
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.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8= go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo=
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
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.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0/go.mod h1:leO2CSTg0Y+LyvmR7Wm4pUxE8KAmaM2GCVx7O+RATLA= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0 h1:0NIXxOCFx+SKbhCVxwl3ETG8ClLPAa0KuKV6p3yhxP8= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.35.0/go.mod h1:ChZSJbbfbl/DcRZNc9Gqh6DYGlfjw4PvO1pEOZH1ZsE= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 h1:xJ2qHD0C1BeYVTLLR9sX12+Qb95kfeD/byKj6Ky1pXg= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0/go.mod h1:u5BF1xyjstDowA1R5QAO9JHzqK+ublenEW/dyqTjBVk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/exporters/prometheus v0.57.0/go.mod h1:QpFWz1QxqevfjwzYdbMb4Y1NnlJvqSGwyuU0B4iuc9c= go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 h1:GKCEAZLEpEf78cUvudQdTg0aET2ObOZRB2HtXA0qPAI= go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0/go.mod h1:9/zqSWLCmHT/9Jo6fYeUDRRogOLL60ABLsHWS99lF8s= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX51SXyTSoOTqcDglmsk7nT6tkKPb/k= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E= go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY= go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg= go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw= go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo= go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o= go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4= go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
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.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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-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.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
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.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4= golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c= golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.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.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-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=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.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.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.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= google.golang.org/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-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
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-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 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=
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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4= gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE= gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5 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/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= 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.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM= modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
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.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -461,8 +459,8 @@ 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.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
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=

View File

@@ -2,68 +2,76 @@ package bootstrap
import ( import (
"bytes" "bytes"
"context"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"io"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"path" "path"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
// initApplicationImages copies the images from the images directory to the application-images directory // initApplicationImages copies the images from the embedded directory to the storage backend
// and returns a map containing the detected file extensions in the application-images directory. // and returns a map containing the detected file extensions in the application-images directory.
func initApplicationImages() (map[string]string, error) { func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images // Previous versions of images
// If these are found, they are deleted // If these are found, they are deleted
legacyImageHashes := imageHashMap{ legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"), "background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
} }
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) {
return nil, fmt.Errorf("failed to read directory: %w", err) return nil, fmt.Errorf("failed to read directory: %w", err)
} }
destinationFiles, err := os.ReadDir(dirPath) destinationFiles, err := fileStorage.List(ctx, "application-images")
if err != nil && !os.IsNotExist(err) { if err != nil {
return nil, fmt.Errorf("failed to read directory: %w", err) if storage.IsNotExist(err) {
destinationFiles = []storage.ObjectInfo{}
} else {
return nil, fmt.Errorf("failed to list application images: %w", err)
}
} }
dstNameToExt := make(map[string]string, len(destinationFiles)) dstNameToExt := make(map[string]string, len(destinationFiles))
for _, f := range destinationFiles { for _, f := range destinationFiles {
if f.IsDir() {
continue
}
name := f.Name()
nameWithoutExt, ext := utils.SplitFileName(name)
destFilePath := path.Join(dirPath, name)
// Skip directories // Skip directories
if f.IsDir() { _, name := path.Split(f.Path)
if name == "" {
continue continue
} }
nameWithoutExt, ext := utils.SplitFileName(name)
h, err := utils.CreateSha256FileHash(destFilePath) reader, _, err := fileStorage.Open(ctx, f.Path)
if err != nil { if err != nil {
slog.Warn("Failed to get hash for file", slog.String("name", name), slog.Any("error", err)) if errors.Is(err, fs.ErrNotExist) {
continue
}
slog.Warn("Failed to open application image for hashing", slog.String("name", name), slog.Any("error", err))
continue
}
hash, err := hashStream(reader)
reader.Close()
if err != nil {
slog.Warn("Failed to hash application image", slog.String("name", name), slog.Any("error", err))
continue continue
} }
// Check if the file is a legacy one - if so, delete it // Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(h) { if legacyImageHashes.Contains(hash) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name)) slog.Info("Found legacy application image that will be removed", slog.String("name", name))
err = os.Remove(destFilePath) if err := fileStorage.Delete(ctx, f.Path); err != nil {
if err != nil {
return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err) return nil, fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
} }
continue continue
} }
// Track existing files
dstNameToExt[nameWithoutExt] = ext dstNameToExt[nameWithoutExt] = ext
} }
@@ -76,21 +84,21 @@ func initApplicationImages() (map[string]string, error) {
name := sourceFile.Name() name := sourceFile.Name()
nameWithoutExt, ext := utils.SplitFileName(name) nameWithoutExt, ext := utils.SplitFileName(name)
srcFilePath := path.Join("images", name) srcFilePath := path.Join("images", name)
destFilePath := path.Join(dirPath, name)
// Skip if there's already an image at the path
// We do not check the extension because users could have uploaded a different one
if _, exists := dstNameToExt[nameWithoutExt]; exists { if _, exists := dstNameToExt[nameWithoutExt]; exists {
continue continue
} }
slog.Info("Writing new application image", slog.String("name", name)) slog.Info("Writing new application image", slog.String("name", name))
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath) srcFile, err := resources.FS.Open(srcFilePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to copy file: %w", err) return nil, fmt.Errorf("failed to open embedded file '%s': %w", name, err)
} }
if err := fileStorage.Save(ctx, path.Join("application-images", name), srcFile); err != nil {
// Track the newly copied file so it can be included in the extensions map later srcFile.Close()
return nil, fmt.Errorf("failed to store application image '%s': %w", name, err)
}
srcFile.Close()
dstNameToExt[nameWithoutExt] = ext dstNameToExt[nameWithoutExt] = ext
} }
@@ -118,3 +126,11 @@ func mustDecodeHex(str string) []byte {
} }
return b return b
} }
func hashStream(r io.Reader) ([]byte, error) {
h := sha256.New()
if _, err := io.Copy(h, r); err != nil {
return nil, err
}
return h.Sum(nil), nil
}

View File

@@ -10,6 +10,7 @@ import (
"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/job" "github.com/pocket-id/pocket-id/backend/internal/job"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )
@@ -21,7 +22,31 @@ func Bootstrap(ctx context.Context) error {
} }
slog.InfoContext(ctx, "Pocket ID is starting") slog.InfoContext(ctx, "Pocket ID is starting")
imageExtensions, err := initApplicationImages() // Initialize the file storage backend
var fileStorage storage.FileStorage
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
case storage.TypeS3:
s3Cfg := storage.S3Config{
Bucket: common.EnvConfig.S3Bucket,
Region: common.EnvConfig.S3Region,
Endpoint: common.EnvConfig.S3Endpoint,
AccessKeyID: common.EnvConfig.S3AccessKeyID,
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
Root: common.EnvConfig.UploadPath,
}
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
default:
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
}
if err != nil {
return fmt.Errorf("failed to initialize file storage: %w", err)
}
imageExtensions, err := initApplicationImages(ctx, fileStorage)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize application images: %w", err) return fmt.Errorf("failed to initialize application images: %w", err)
} }
@@ -33,7 +58,7 @@ func Bootstrap(ctx context.Context) error {
} }
// Create all services // Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions) svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
if err != nil { if err != nil {
return fmt.Errorf("failed to initialize services: %w", err) return fmt.Errorf("failed to initialize services: %w", err)
} }

View File

@@ -1,6 +1,7 @@
package bootstrap package bootstrap
import ( import (
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -140,6 +141,7 @@ func connectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector var dialector gorm.Dialector
// Choose the correct database provider // Choose the correct database provider
var onConnFn func(conn *sql.DB)
switch common.EnvConfig.DbProvider { switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite: case common.DbProviderSqlite:
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
@@ -148,7 +150,7 @@ func connectDatabase() (db *gorm.DB, err error) {
sqliteutil.RegisterSqliteFunctions() sqliteutil.RegisterSqliteFunctions()
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString) connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -159,6 +161,14 @@ func connectDatabase() (db *gorm.DB, err error) {
return nil, err return nil, err
} }
if isMemoryDB {
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
onConnFn = func(conn *sql.DB) {
conn.SetMaxOpenConns(1)
}
}
dialector = sqlite.Open(connString) dialector = sqlite.Open(connString)
case common.DbProviderPostgres: case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
@@ -176,6 +186,16 @@ func connectDatabase() (db *gorm.DB, err error) {
}) })
if err == nil { if err == nil {
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider))) slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
if onConnFn != nil {
conn, err := db.DB()
if err != nil {
slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
time.Sleep(3 * time.Second)
}
onConnFn(conn)
}
return db, nil return db, nil
} }
@@ -188,18 +208,18 @@ func connectDatabase() (db *gorm.DB, err error) {
return nil, err return nil, err
} }
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) { func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) {
if !strings.HasPrefix(connString, "file:") { if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString connString = "file:" + connString
} }
// Check if we're using an in-memory database // Check if we're using an in-memory database
isMemoryDB := isSqliteInMemory(connString) isMemoryDB = isSqliteInMemory(connString)
// Parse the connection string // Parse the connection string
connStringUrl, err := url.Parse(connString) connStringUrl, err := url.Parse(connString)
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err) return "", "", false, fmt.Errorf("failed to parse SQLite connection string: %w", err)
} }
// Convert options for the old SQLite driver to the new one // Convert options for the old SQLite driver to the new one
@@ -208,7 +228,7 @@ func parseSqliteConnectionString(connString string) (parsedConnString string, db
// Add the default and required params // Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB) err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil { if err != nil {
return "", "", fmt.Errorf("invalid SQLite connection string: %w", err) return "", "", false, fmt.Errorf("invalid SQLite connection string: %w", err)
} }
// Get the absolute path to the database // Get the absolute path to the database
@@ -217,10 +237,10 @@ func parseSqliteConnectionString(connString string) (parsedConnString string, db
idx := strings.IndexRune(parsedConnString, '?') idx := strings.IndexRune(parsedConnString, '?')
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx]) dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
if err != nil { if err != nil {
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err) return "", "", false, fmt.Errorf("failed to determine absolute path to the database: %w", err)
} }
return parsedConnString, dbPath, nil return parsedConnString, dbPath, isMemoryDB, 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
@@ -272,11 +292,6 @@ func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
qs = make(url.Values, 2) qs = make(url.Values, 2)
} }
// If the database is in-memory, we must ensure that cache=shared is set
if isMemoryDB {
qs["cache"] = []string{"shared"}
}
// Check if the database is read-only or immutable // Check if the database is read-only or immutable
isReadOnly := false isReadOnly := false
if len(qs["mode"]) > 0 { if len(qs["mode"]) > 0 {

View File

@@ -205,7 +205,7 @@ func TestAddSqliteDefaultParameters(t *testing.T) {
name: "in-memory database", name: "in-memory database",
input: "file::memory:", input: "file::memory:",
isMemoryDB: true, isMemoryDB: true,
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared", expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate",
}, },
{ {
name: "read-only database with mode=ro", name: "read-only database with mode=ro",
@@ -249,12 +249,6 @@ func TestAddSqliteDefaultParameters(t *testing.T) {
isMemoryDB: false, 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", 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)", name: "database with mode=rw (not read-only)",
input: "file:test.db?mode=rw", input: "file:test.db?mode=rw",

View File

@@ -17,7 +17,7 @@ import (
func init() { func init() {
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){ registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) { func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService) testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.fileStorage)
if err != nil { if err != nil {
slog.Error("Failed to initialize test service", slog.Any("error", err)) slog.Error("Failed to initialize test service", slog.Any("error", err))
os.Exit(1) os.Exit(1)

View File

@@ -119,6 +119,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
if common.EnvConfig.UnixSocket != "" { if common.EnvConfig.UnixSocket != "" {
network = "unix" network = "unix"
addr = common.EnvConfig.UnixSocket addr = common.EnvConfig.UnixSocket
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
} }
listener, err := net.Listen(network, addr) //nolint:noctx listener, err := net.Listen(network, addr) //nolint:noctx

View File

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

View File

@@ -8,6 +8,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/storage"
) )
type services struct { type services struct {
@@ -25,10 +26,11 @@ type services struct {
ldapService *service.LdapService ldapService *service.LdapService
apiKeyService *service.ApiKeyService apiKeyService *service.ApiKeyService
versionService *service.VersionService versionService *service.VersionService
fileStorage storage.FileStorage
} }
// Initializes all services // Initializes all services
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string) (svc *services, err error) { func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, imageExtensions map[string]string, fileStorage storage.FileStorage) (svc *services, err error) {
svc = &services{} svc = &services{}
svc.appConfigService, err = service.NewAppConfigService(ctx, db) svc.appConfigService, err = service.NewAppConfigService(ctx, db)
@@ -36,7 +38,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create app config service: %w", err) return nil, fmt.Errorf("failed to create app config service: %w", err)
} }
svc.appImagesService = service.NewAppImagesService(imageExtensions) svc.fileStorage = fileStorage
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService) svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil { if err != nil {
@@ -56,13 +59,13 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err) 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) svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService, httpClient, fileStorage)
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.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService) 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)

View File

@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"net"
"net/url" "net/url"
"os" "os"
"reflect" "reflect"
@@ -28,16 +29,24 @@ const (
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 = "data/pocket-id.db" defaultSqliteConnString string = "data/pocket-id.db"
defaultFsUploadPath string = "data/uploads"
AppUrl string = "http://localhost:1411" AppUrl string = "http://localhost:1411"
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV" options:"toLower"` AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"` LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL" options:"toLower"` AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"` DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"` KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
@@ -74,27 +83,13 @@ func defaultConfig() EnvConfigSchema {
AppEnv: "production", AppEnv: "production",
LogLevel: "info", LogLevel: "info",
DbProvider: "sqlite", DbProvider: "sqlite",
DbConnectionString: "", FileBackend: "fs",
UploadPath: "data/uploads",
KeysPath: "data/keys", KeysPath: "data/keys",
KeysStorage: "", // "database" or "file"
EncryptionKey: nil,
AppURL: AppUrl, AppURL: AppUrl,
Port: "1411", Port: "1411",
Host: "0.0.0.0", Host: "0.0.0.0",
UnixSocket: "",
UnixSocketMode: "",
MaxMindLicenseKey: "",
GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl, GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
LocalIPv6Ranges: "",
UiConfigDisabled: false,
MetricsEnabled: false,
TracingEnabled: false,
TrustProxy: false,
AnalyticsDisabled: false,
AllowDowngrade: false,
InternalAppURL: "",
} }
} }
@@ -180,6 +175,38 @@ func validateEnvConfig(config *EnvConfigSchema) error {
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage) return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
} }
switch config.FileBackend {
case "s3":
if config.KeysStorage == "file" {
return errors.New("KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
}
case "", "fs":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
}
default:
return errors.New("invalid FILE_BACKEND value. Must be 'fs' or 's3'")
}
// Validate LOCAL_IPV6_RANGES
ranges := strings.Split(config.LocalIPv6Ranges, ",")
for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
}
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
}
return nil return nil
} }
@@ -207,6 +234,10 @@ func prepareEnvConfig(config *EnvConfigSchema) error {
if err != nil { if err != nil {
return err return err
} }
case "trimTrailingSlash":
if field.Kind() == reflect.String {
field.SetString(strings.TrimRight(field.String(), "/"))
}
} }
} }
} }

View File

@@ -208,6 +208,44 @@ func TestParseEnvConfig(t *testing.T) {
assert.Equal(t, "8080", EnvConfig.Port) assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
}) })
t.Run("should normalize file backend and default upload path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "FS")
t.Setenv("UPLOAD_PATH", "")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "fs", EnvConfig.FileBackend)
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
})
t.Run("should fail when FILE_BACKEND is s3 but keys are stored on filesystem", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "s3")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
})
t.Run("should fail with invalid FILE_BACKEND value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "invalid")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
})
} }
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) { func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {

View File

@@ -378,3 +378,23 @@ func (e *ClientIdAlreadyExistsError) Error() string {
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int { func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
return http.StatusBadRequest return http.StatusBadRequest
} }
type UserEmailNotSetError struct{}
func (e *UserEmailNotSetError) Error() string {
return "The user does not have an email address set"
}
func (e *UserEmailNotSetError) HttpStatusCode() int {
return http.StatusBadRequest
}
type ImageNotFoundError struct{}
func (e *ImageNotFoundError) Error() string {
return "Image not found"
}
func (e *ImageNotFoundError) HttpStatusCode() int {
return http.StatusNotFound
}

View File

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

View File

@@ -25,10 +25,14 @@ func NewAppImagesController(
group.GET("/application-images/logo", controller.getLogoHandler) group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler) group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler) group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler) group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler) group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler) group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
} }
type AppImagesController struct { type AppImagesController struct {
@@ -78,6 +82,18 @@ func (c *AppImagesController) getFaviconHandler(ctx *gin.Context) {
c.getImage(ctx, "favicon") c.getImage(ctx, "favicon")
} }
// getDefaultProfilePicture godoc
// @Summary Get default profile picture image
// @Description Get the default profile picture image for the application
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Default profile picture image"
// @Router /api/application-images/default-profile-picture [get]
func (c *AppImagesController) getDefaultProfilePicture(ctx *gin.Context) {
c.getImage(ctx, "default-profile-picture")
}
// updateLogoHandler godoc // updateLogoHandler godoc
// @Summary Update logo // @Summary Update logo
// @Description Update the application logo // @Description Update the application logo
@@ -100,7 +116,7 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
imageName = "logoDark" imageName = "logoDark"
} }
if err := c.appImagesService.UpdateImage(file, imageName); err != nil { if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, imageName); err != nil {
_ = ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -123,7 +139,7 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
return return
} }
if err := c.appImagesService.UpdateImage(file, "background"); err != nil { if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "background"); err != nil {
_ = ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -152,7 +168,7 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
return return
} }
if err := c.appImagesService.UpdateImage(file, "favicon"); err != nil { if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "favicon"); err != nil {
_ = ctx.Error(err) _ = ctx.Error(err)
return return
} }
@@ -161,13 +177,52 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
} }
func (c *AppImagesController) getImage(ctx *gin.Context, name string) { func (c *AppImagesController) getImage(ctx *gin.Context, name string) {
imagePath, mimeType, err := c.appImagesService.GetImage(name) reader, size, mimeType, err := c.appImagesService.GetImage(ctx.Request.Context(), name)
if err != nil {
_ = ctx.Error(err)
return
}
defer reader.Close()
ctx.Header("Content-Type", mimeType)
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour)
ctx.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
}
// updateDefaultProfilePicture godoc
// @Summary Update default profile picture image
// @Description Update the default profile picture image
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Profile picture image file"
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [put]
func (c *AppImagesController) updateDefaultProfilePicture(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil { if err != nil {
_ = ctx.Error(err) _ = ctx.Error(err)
return return
} }
ctx.Header("Content-Type", mimeType) if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "default-profile-picture"); err != nil {
utils.SetCacheControlHeader(ctx, 15*time.Minute, 24*time.Hour) _ = ctx.Error(err)
ctx.File(imagePath) return
}
ctx.Status(http.StatusNoContent)
}
// deleteDefaultProfilePicture godoc
// @Summary Delete default profile picture image
// @Description Delete the default profile picture image
// @Tags Application Images
// @Success 204 "No Content"
// @Router /api/application-images/default-profile-picture [delete]
func (c *AppImagesController) deleteDefaultProfilePicture(ctx *gin.Context) {
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "default-profile-picture"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
} }

View File

@@ -41,18 +41,12 @@ type AuditLogController struct {
// @Success 200 {object} dto.Paginated[dto.AuditLogDto] // @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs [get] // @Router /api/audit-logs [get]
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) { func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest listRequestOptions := utils.ParseListRequestOptions(c)
err := c.ShouldBindQuery(&sortedPaginationRequest)
if err != nil {
_ = c.Error(err)
return
}
userID := c.GetString("userID") userID := c.GetString("userID")
// Fetch audit logs for the user // Fetch audit logs for the user
logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, sortedPaginationRequest) logs, pagination, err := alc.auditLogService.ListAuditLogsForUser(c.Request.Context(), userID, listRequestOptions)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -86,26 +80,12 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
// @Param pagination[limit] query int false "Number of items per page" default(20) // @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[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")
// @Param filters[userId] query string false "Filter by user ID"
// @Param filters[event] query string false "Filter by event type"
// @Param filters[clientName] query string false "Filter by client name"
// @Param filters[location] query string false "Filter by location type (external or internal)"
// @Success 200 {object} dto.Paginated[dto.AuditLogDto] // @Success 200 {object} dto.Paginated[dto.AuditLogDto]
// @Router /api/audit-logs/all [get] // @Router /api/audit-logs/all [get]
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) { func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest listRequestOptions := utils.ParseListRequestOptions(c)
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
var filters dto.AuditLogFilterDto logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), listRequestOptions)
if err := c.ShouldBindQuery(&filters); err != nil {
_ = c.Error(err)
return
}
logs, pagination, err := alc.auditLogService.ListAllAuditLogs(c.Request.Context(), sortedPaginationRequest, filters)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -5,6 +5,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"net/url" "net/url"
"strconv"
"strings" "strings"
"time" "time"
@@ -357,6 +358,7 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
clientDto := dto.OidcClientMetaDataDto{} clientDto := dto.OidcClientMetaDataDto{}
err = dto.MapStruct(client, &clientDto) err = dto.MapStruct(client, &clientDto)
if err == nil { if err == nil {
clientDto.HasDarkLogo = client.HasDarkLogo()
c.JSON(http.StatusOK, clientDto) c.JSON(http.StatusOK, clientDto)
return return
} }
@@ -403,13 +405,9 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
// @Router /api/oidc/clients [get] // @Router /api/oidc/clients [get]
func (oc *OidcController) listClientsHandler(c *gin.Context) { func (oc *OidcController) listClientsHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest listRequestOptions := utils.ParseListRequestOptions(c)
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, sortedPaginationRequest) clients, pagination, err := oc.oidcService.ListClients(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -423,6 +421,7 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
_ = c.Error(err) _ = c.Error(err)
return return
} }
clientDto.HasDarkLogo = client.HasDarkLogo()
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID) clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
@@ -543,19 +542,23 @@ func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
// @Produce image/jpeg // @Produce image/jpeg
// @Produce image/svg+xml // @Produce image/svg+xml
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 200 {file} binary "Logo image" // @Success 200 {file} binary "Logo image"
// @Router /api/oidc/clients/{id}/logo [get] // @Router /api/oidc/clients/{id}/logo [get]
func (oc *OidcController) getClientLogoHandler(c *gin.Context) { func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
imagePath, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id")) lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
reader, size, mimeType, err := oc.oidcService.GetClientLogo(c.Request.Context(), c.Param("id"), lightLogo)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
defer reader.Close()
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour) utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
c.Header("Content-Type", mimeType) c.Header("Content-Type", mimeType)
c.File(imagePath) c.DataFromReader(http.StatusOK, size, mimeType, reader, nil)
} }
// updateClientLogoHandler godoc // updateClientLogoHandler godoc
@@ -565,6 +568,7 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
// @Accept multipart/form-data // @Accept multipart/form-data
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)" // @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content" // @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [post] // @Router /api/oidc/clients/{id}/logo [post]
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) { func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
@@ -574,13 +578,16 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
return return
} }
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file) lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
err = oc.oidcService.UpdateClientLogo(c.Request.Context(), c.Param("id"), file, lightLogo)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
// deleteClientLogoHandler godoc // deleteClientLogoHandler godoc
@@ -588,16 +595,26 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
// @Description Delete the logo for an OIDC client // @Description Delete the logo for an OIDC client
// @Tags OIDC // @Tags OIDC
// @Param id path string true "Client ID" // @Param id path string true "Client ID"
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
// @Success 204 "No Content" // @Success 204 "No Content"
// @Router /api/oidc/clients/{id}/logo [delete] // @Router /api/oidc/clients/{id}/logo [delete]
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) { func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id")) var err error
lightLogo, _ := strconv.ParseBool(c.DefaultQuery("light", "true"))
if lightLogo {
err = oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
} else {
err = oc.oidcService.DeleteClientDarkLogo(c.Request.Context(), c.Param("id"))
}
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
c.Status(http.StatusNoContent) c.Status(http.StatusNoContent)
} }
// updateAllowedUserGroupsHandler godoc // updateAllowedUserGroupsHandler godoc
@@ -628,6 +645,7 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
_ = c.Error(err) _ = c.Error(err)
return return
} }
oidcClientDto.HasDarkLogo = oidcClient.HasDarkLogo()
c.JSON(http.StatusOK, oidcClientDto) c.JSON(http.StatusOK, oidcClientDto)
} }
@@ -685,12 +703,9 @@ func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
} }
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) { func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
var sortedPaginationRequest utils.SortedPaginationRequest listRequestOptions := utils.ParseListRequestOptions(c)
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err) authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, listRequestOptions)
return
}
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -741,15 +756,11 @@ func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto] // @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
// @Router /api/oidc/users/me/clients [get] // @Router /api/oidc/users/me/clients [get]
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) { func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
listRequestOptions := utils.ParseListRequestOptions(c)
userID := c.GetString("userID") userID := c.GetString("userID")
var sortedPaginationRequest utils.SortedPaginationRequest clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, listRequestOptions)
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 { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -104,13 +104,9 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
// @Router /api/users [get] // @Router /api/users [get]
func (uc *UserController) listUsersHandler(c *gin.Context) { func (uc *UserController) listUsersHandler(c *gin.Context) {
searchTerm := c.Query("search") searchTerm := c.Query("search")
var sortedPaginationRequest utils.SortedPaginationRequest listRequestOptions := utils.ParseListRequestOptions(c)
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, sortedPaginationRequest) users, pagination, err := uc.userService.ListUsers(c.Request.Context(), searchTerm, listRequestOptions)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -290,7 +286,7 @@ func (uc *UserController) updateUserProfilePictureHandler(c *gin.Context) {
} }
defer file.Close() defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil { if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -321,7 +317,7 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
} }
defer file.Close() defer file.Close()
if err := uc.userService.UpdateProfilePicture(userID, file); err != nil { if err := uc.userService.UpdateProfilePicture(c.Request.Context(), userID, file); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -574,13 +570,9 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto] // @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
// @Router /api/signup-tokens [get] // @Router /api/signup-tokens [get]
func (uc *UserController) listSignupTokensHandler(c *gin.Context) { func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
var sortedPaginationRequest utils.SortedPaginationRequest listRequestOptions := utils.ParseListRequestOptions(c)
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
_ = c.Error(err)
return
}
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest) tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), listRequestOptions)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -695,7 +687,7 @@ func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) { func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
userID := c.Param("id") userID := c.Param("id")
if err := uc.userService.ResetProfilePicture(userID); err != nil { if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -713,7 +705,7 @@ func (uc *UserController) resetUserProfilePictureHandler(c *gin.Context) {
func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) { func (uc *UserController) resetCurrentUserProfilePictureHandler(c *gin.Context) {
userID := c.GetString("userID") userID := c.GetString("userID")
if err := uc.userService.ResetProfilePicture(userID); err != nil { if err := uc.userService.ResetProfilePicture(c.Request.Context(), userID); err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }

View File

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

View File

@@ -21,6 +21,7 @@ type AppConfigUpdateDto struct {
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"` SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"` SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
AccentColor string `json:"accentColor"` AccentColor string `json:"accentColor"`
RequireUserEmail string `json:"requireUserEmail" binding:"required"`
SmtpHost string `json:"smtpHost"` SmtpHost string `json:"smtpHost"`
SmtpPort string `json:"smtpPort"` SmtpPort string `json:"smtpPort"`
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"` SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`

View File

@@ -17,10 +17,3 @@ type AuditLogDto struct {
Username string `json:"username"` Username string `json:"username"`
Data map[string]string `json:"data"` Data map[string]string `json:"data"`
} }
type AuditLogFilterDto struct {
UserID string `form:"filters[userId]"`
Event string `form:"filters[event]"`
ClientName string `form:"filters[clientName]"`
Location string `form:"filters[location]"`
}

View File

@@ -6,6 +6,7 @@ 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"`
HasDarkLogo bool `json:"hasDarkLogo"`
LaunchURL *string `json:"launchURL"` LaunchURL *string `json:"launchURL"`
RequiresReauthentication bool `json:"requiresReauthentication"` RequiresReauthentication bool `json:"requiresReauthentication"`
} }
@@ -38,6 +39,10 @@ type OidcClientUpdateDto struct {
RequiresReauthentication bool `json:"requiresReauthentication"` RequiresReauthentication bool `json:"requiresReauthentication"`
Credentials OidcClientCredentialsDto `json:"credentials"` Credentials OidcClientCredentialsDto `json:"credentials"`
LaunchURL *string `json:"launchURL" binding:"omitempty,url"` LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
HasLogo bool `json:"hasLogo"`
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
} }
type OidcClientCreateDto struct { type OidcClientCreateDto struct {

View File

@@ -10,7 +10,7 @@ import (
type UserDto struct { type UserDto struct {
ID string `json:"id"` ID string `json:"id"`
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email" ` Email *string `json:"email" `
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName *string `json:"lastName"` LastName *string `json:"lastName"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
@@ -24,10 +24,10 @@ type UserDto struct {
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"` DisplayName string `json:"displayName" binding:"required,min=1,max=100" 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"`
@@ -65,7 +65,7 @@ type UserUpdateUserGroupDto struct {
type SignUpDto struct { type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"` Token string `json:"token"`

View File

@@ -3,6 +3,7 @@ package dto
import ( import (
"testing" "testing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -16,7 +17,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "valid input", name: "valid input",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "test@example.com", Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -26,7 +27,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
{ {
name: "missing username", name: "missing username",
input: UserCreateDto{ input: UserCreateDto{
Email: "test@example.com", Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -36,7 +37,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
{ {
name: "missing display name", name: "missing display name",
input: UserCreateDto{ input: UserCreateDto{
Email: "test@example.com", Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
}, },
@@ -46,7 +47,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "username contains invalid characters", name: "username contains invalid characters",
input: UserCreateDto{ input: UserCreateDto{
Username: "test/ser", Username: "test/ser",
Email: "test@example.com", Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -57,7 +58,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "invalid email", name: "invalid email",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "not-an-email", Email: utils.Ptr("not-an-email"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -68,7 +69,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "first name too short", name: "first name too short",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "test@example.com", Email: utils.Ptr("test@example.com"),
FirstName: "", FirstName: "",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -79,7 +80,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "last name too long", name: "last name too long",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: "test@example.com", Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe", DisplayName: "John Doe",

View File

@@ -67,14 +67,12 @@ func ValidateClientID(clientID string) bool {
// ValidateCallbackURL validates callback URLs with support for wildcards // ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool { func ValidateCallbackURL(raw string) bool {
if raw == "*" { // Don't validate if it contains a wildcard
if strings.Contains(raw, "*") {
return true return true
} }
// Replace all '*' with 'x' to check if the rest is still a valid URI u, err := url.Parse(raw)
test := strings.ReplaceAll(raw, "*", "x")
u, err := url.Parse(test)
if err != nil { if err != nil {
return false return false
} }

View File

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

View File

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

View File

@@ -34,7 +34,7 @@ func (m *CspMiddleware) Add() gin.HandlerFunc {
"object-src 'none'; " + "object-src 'none'; " +
"frame-ancestors 'none'; " + "frame-ancestors 'none'; " +
"form-action 'self'; " + "form-action 'self'; " +
"img-src 'self' data: blob:; " + "img-src * blob:;" +
"font-src 'self'; " + "font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'" "script-src 'self' 'nonce-" + nonce + "'"

View File

@@ -46,6 +46,7 @@ type AppConfig struct {
// Internal // Internal
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
// Email // Email
RequireUserEmail AppConfigVariable `key:"requireUserEmail,public"` // Public
SmtpHost AppConfigVariable `key:"smtpHost"` SmtpHost AppConfigVariable `key:"smtpHost"`
SmtpPort AppConfigVariable `key:"smtpPort"` SmtpPort AppConfigVariable `key:"smtpPort"`
SmtpFrom AppConfigVariable `key:"smtpFrom"` SmtpFrom AppConfigVariable `key:"smtpFrom"`

View File

@@ -3,13 +3,14 @@ package model
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"fmt"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
type AuditLog struct { type AuditLog struct {
Base Base
Event AuditLogEvent `sortable:"true"` Event AuditLogEvent `sortable:"true" filterable:"true"`
IpAddress *string `sortable:"true"` IpAddress *string `sortable:"true"`
Country string `sortable:"true"` Country string `sortable:"true"`
City string `sortable:"true"` City string `sortable:"true"`
@@ -17,7 +18,7 @@ type AuditLog struct {
Username string `gorm:"-"` Username string `gorm:"-"`
Data AuditLogData Data AuditLogData
UserID string UserID string `filterable:"true"`
User User User User
} }
@@ -47,14 +48,7 @@ func (e AuditLogEvent) Value() (driver.Value, error) {
} }
func (d *AuditLogData) Scan(value any) error { func (d *AuditLogData) Scan(value any) error {
switch v := value.(type) { return utils.UnmarshalJSONFromDatabase(d, value)
case []byte:
return json.Unmarshal(v, d)
case string:
return json.Unmarshal([]byte(v), d)
default:
return fmt.Errorf("unsupported type: %T", value)
}
} }
func (d AuditLogData) Value() (driver.Value, error) { func (d AuditLogData) Value() (driver.Value, error) {

View File

@@ -3,12 +3,10 @@ package model
import ( import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"fmt"
"strings" "strings"
"gorm.io/gorm"
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 UserAuthorizedOidcClient struct { type UserAuthorizedOidcClient struct {
@@ -54,10 +52,10 @@ type OidcClient struct {
CallbackURLs UrlList CallbackURLs UrlList
LogoutCallbackURLs UrlList LogoutCallbackURLs UrlList
ImageType *string ImageType *string
HasLogo bool `gorm:"-"` DarkImageType *string
IsPublic bool IsPublic bool
PkceEnabled bool PkceEnabled bool `sortable:"true" filterable:"true"`
RequiresReauthentication bool RequiresReauthentication bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials Credentials OidcClientCredentials
LaunchURL *string LaunchURL *string
@@ -67,6 +65,14 @@ type OidcClient struct {
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"` UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
} }
func (c OidcClient) HasLogo() bool {
return c.ImageType != nil && *c.ImageType != ""
}
func (c OidcClient) HasDarkLogo() bool {
return c.DarkImageType != nil && *c.DarkImageType != ""
}
type OidcRefreshToken struct { type OidcRefreshToken struct {
Base Base
@@ -89,12 +95,6 @@ func (c OidcRefreshToken) Scopes() []string {
return strings.Split(c.Scope, " ") return strings.Split(c.Scope, " ")
} }
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
return nil
}
type OidcClientCredentials struct { //nolint:recvcheck type OidcClientCredentials struct { //nolint:recvcheck
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"` FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
} }
@@ -121,14 +121,7 @@ func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (Oidc
} }
func (occ *OidcClientCredentials) Scan(value any) error { func (occ *OidcClientCredentials) Scan(value any) error {
switch v := value.(type) { return utils.UnmarshalJSONFromDatabase(occ, value)
case []byte:
return json.Unmarshal(v, occ)
case string:
return json.Unmarshal([]byte(v), occ)
default:
return fmt.Errorf("unsupported type: %T", value)
}
} }
func (occ OidcClientCredentials) Value() (driver.Value, error) { func (occ OidcClientCredentials) Value() (driver.Value, error) {
@@ -138,14 +131,7 @@ func (occ OidcClientCredentials) Value() (driver.Value, error) {
type UrlList []string //nolint:recvcheck type UrlList []string //nolint:recvcheck
func (cu *UrlList) Scan(value any) error { func (cu *UrlList) Scan(value any) error {
switch v := value.(type) { return utils.UnmarshalJSONFromDatabase(cu, value)
case []byte:
return json.Unmarshal(v, cu)
case string:
return json.Unmarshal([]byte(v), cu)
default:
return fmt.Errorf("unsupported type: %T", value)
}
} }
func (cu UrlList) Value() (driver.Value, error) { func (cu UrlList) Value() (driver.Value, error) {

View File

@@ -14,14 +14,14 @@ type User struct {
Base Base
Username string `sortable:"true"` Username string `sortable:"true"`
Email string `sortable:"true"` Email *string `sortable:"true"`
FirstName string `sortable:"true"` FirstName string `sortable:"true"`
LastName string `sortable:"true"` LastName string `sortable:"true"`
DisplayName string `sortable:"true"` DisplayName string `sortable:"true"`
IsAdmin bool `sortable:"true"` IsAdmin bool `sortable:"true" filterable:"true"`
Locale *string Locale *string
LdapID *string LdapID *string
Disabled bool `sortable:"true"` Disabled bool `sortable:"true" filterable:"true"`
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`

View File

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

View File

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

View File

@@ -71,6 +71,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
// Internal // Internal
InstanceID: model.AppConfigVariable{Value: ""}, InstanceID: model.AppConfigVariable{Value: ""},
// Email // Email
RequireUserEmail: model.AppConfigVariable{Value: "true"},
SmtpHost: model.AppConfigVariable{}, SmtpHost: model.AppConfigVariable{},
SmtpPort: model.AppConfigVariable{}, SmtpPort: model.AppConfigVariable{},
SmtpFrom: model.AppConfigVariable{}, SmtpFrom: model.AppConfigVariable{},

View File

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

View File

@@ -2,66 +2,92 @@ package service
import ( import (
"bytes" "bytes"
"context"
"io"
"io/fs" "io/fs"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "path"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"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/storage"
) )
func TestAppImagesService_GetImage(t *testing.T) { func TestAppImagesService_GetImage(t *testing.T) {
tempDir := t.TempDir() store, err := storage.NewFilesystemStorage(t.TempDir())
originalUploadPath := common.EnvConfig.UploadPath
common.EnvConfig.UploadPath = tempDir
t.Cleanup(func() {
common.EnvConfig.UploadPath = originalUploadPath
})
imagesDir := filepath.Join(tempDir, "application-images")
require.NoError(t, os.MkdirAll(imagesDir, 0o755))
filePath := filepath.Join(imagesDir, "background.webp")
require.NoError(t, os.WriteFile(filePath, []byte("data"), fs.FileMode(0o644)))
service := NewAppImagesService(map[string]string{"background": "webp"})
path, mimeType, err := service.GetImage("background")
require.NoError(t, err) require.NoError(t, err)
require.Equal(t, filePath, path)
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "background.webp"), bytes.NewReader([]byte("data"))))
service := NewAppImagesService(map[string]string{"background": "webp"}, store)
reader, size, mimeType, err := service.GetImage(context.Background(), "background")
require.NoError(t, err)
defer reader.Close()
payload, err := io.ReadAll(reader)
require.NoError(t, err)
require.Equal(t, []byte("data"), payload)
require.Equal(t, int64(len(payload)), size)
require.Equal(t, "image/webp", mimeType) require.Equal(t, "image/webp", mimeType)
} }
func TestAppImagesService_UpdateImage(t *testing.T) { func TestAppImagesService_UpdateImage(t *testing.T) {
tempDir := t.TempDir() store, err := storage.NewFilesystemStorage(t.TempDir())
originalUploadPath := common.EnvConfig.UploadPath require.NoError(t, err)
common.EnvConfig.UploadPath = tempDir
t.Cleanup(func() {
common.EnvConfig.UploadPath = originalUploadPath
})
imagesDir := filepath.Join(tempDir, "application-images") require.NoError(t, store.Save(context.Background(), path.Join("application-images", "logoLight.svg"), bytes.NewReader([]byte("old"))))
require.NoError(t, os.MkdirAll(imagesDir, 0o755))
oldPath := filepath.Join(imagesDir, "logoLight.svg") service := NewAppImagesService(map[string]string{"logoLight": "svg"}, store)
require.NoError(t, os.WriteFile(oldPath, []byte("old"), fs.FileMode(0o644)))
service := NewAppImagesService(map[string]string{"logoLight": "svg"})
fileHeader := newFileHeader(t, "logoLight.png", []byte("new")) fileHeader := newFileHeader(t, "logoLight.png", []byte("new"))
require.NoError(t, service.UpdateImage(fileHeader, "logoLight")) require.NoError(t, service.UpdateImage(context.Background(), fileHeader, "logoLight"))
_, err := os.Stat(filepath.Join(imagesDir, "logoLight.png")) reader, _, err := store.Open(context.Background(), path.Join("application-images", "logoLight.png"))
require.NoError(t, err)
_ = reader.Close()
_, _, err = store.Open(context.Background(), path.Join("application-images", "logoLight.svg"))
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestAppImagesService_ErrorsAndFlags(t *testing.T) {
store, err := storage.NewFilesystemStorage(t.TempDir())
require.NoError(t, err) require.NoError(t, err)
_, err = os.Stat(oldPath) service := NewAppImagesService(map[string]string{}, store)
require.ErrorIs(t, err, os.ErrNotExist)
t.Run("get missing image returns not found", func(t *testing.T) {
_, _, _, err := service.GetImage(context.Background(), "missing")
require.Error(t, err)
var imageErr *common.ImageNotFoundError
assert.ErrorAs(t, err, &imageErr)
})
t.Run("reject unsupported file types", func(t *testing.T) {
err := service.UpdateImage(context.Background(), newFileHeader(t, "logo.txt", []byte("nope")), "logo")
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
assert.ErrorAs(t, err, &fileTypeErr)
})
t.Run("delete and extension tracking", func(t *testing.T) {
require.NoError(t, store.Save(context.Background(), path.Join("application-images", "default-profile-picture.png"), bytes.NewReader([]byte("img"))))
service.extensions["default-profile-picture"] = "png"
require.NoError(t, service.DeleteImage(context.Background(), "default-profile-picture"))
assert.False(t, service.IsDefaultProfilePictureSet())
err := service.DeleteImage(context.Background(), "default-profile-picture")
require.Error(t, err)
var imageErr *common.ImageNotFoundError
assert.ErrorAs(t, err, &imageErr)
})
} }
func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader { func newFileHeader(t *testing.T, filename string, content []byte) *multipart.FileHeader {

View File

@@ -6,7 +6,6 @@ import (
"log/slog" "log/slog"
userAgentParser "github.com/mileusna/useragent" userAgentParser "github.com/mileusna/useragent"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/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"
@@ -111,9 +110,13 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
return return
} }
if user.Email == nil {
return
}
innerErr = SendEmail(innerCtx, s.emailService, email.Address{ innerErr = SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(), Name: user.FullName(),
Email: user.Email, Email: *user.Email,
}, NewLoginTemplate, &NewLoginTemplateData{ }, NewLoginTemplate, &NewLoginTemplateData{
IPAddress: ipAddress, IPAddress: ipAddress,
Country: createdAuditLog.Country, Country: createdAuditLog.Country,
@@ -122,7 +125,7 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
DateTime: createdAuditLog.CreatedAt.UTC(), DateTime: createdAuditLog.CreatedAt.UTC(),
}) })
if innerErr != nil { if innerErr != nil {
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", user.Email)) slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", *user.Email))
return return
} }
}() }()
@@ -132,14 +135,14 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
} }
// ListAuditLogsForUser retrieves all audit logs for a given user ID // ListAuditLogsForUser retrieves all audit logs for a given user ID
func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.AuditLog, utils.PaginationResponse, error) { func (s *AuditLogService) ListAuditLogsForUser(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog var logs []model.AuditLog
query := s.db. query := s.db.
WithContext(ctx). WithContext(ctx).
Model(&model.AuditLog{}). Model(&model.AuditLog{}).
Where("user_id = ?", userID) Where("user_id = ?", userID)
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
return logs, pagination, err return logs, pagination, err
} }
@@ -148,7 +151,7 @@ func (s *AuditLogService) DeviceStringFromUserAgent(userAgent string) string {
return ua.Name + " on " + ua.OS + " " + ua.OSVersion return ua.Name + " on " + ua.OS + " " + ua.OSVersion
} }
func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest, filters dto.AuditLogFilterDto) ([]model.AuditLog, utils.PaginationResponse, error) { func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.AuditLog, utils.PaginationResponse, error) {
var logs []model.AuditLog var logs []model.AuditLog
query := s.db. query := s.db.
@@ -156,33 +159,36 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
Preload("User"). Preload("User").
Model(&model.AuditLog{}) Model(&model.AuditLog{})
if filters.UserID != "" { if clientName, ok := listRequestOptions.Filters["clientName"]; ok {
query = query.Where("user_id = ?", filters.UserID)
}
if filters.Event != "" {
query = query.Where("event = ?", filters.Event)
}
if filters.ClientName != "" {
dialect := s.db.Name() dialect := s.db.Name()
switch dialect { switch dialect {
case "sqlite": case "sqlite":
query = query.Where("json_extract(data, '$.clientName') = ?", filters.ClientName) query = query.Where("json_extract(data, '$.clientName') IN ?", clientName)
case "postgres": case "postgres":
query = query.Where("data->>'clientName' = ?", filters.ClientName) query = query.Where("data->>'clientName' IN ?", clientName)
default: default:
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect) return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
} }
} }
if filters.Location != "" {
switch filters.Location { if locations, ok := listRequestOptions.Filters["location"]; ok {
case "external": mapped := make([]string, 0, len(locations))
query = query.Where("country != 'Internal Network'") for _, v := range locations {
if s, ok := v.(string); ok {
switch s {
case "internal": case "internal":
query = query.Where("country = 'Internal Network'") mapped = append(mapped, "Internal Network")
case "external":
mapped = append(mapped, "External Network")
}
}
}
if len(mapped) > 0 {
query = query.Where("country IN ?", mapped)
} }
} }
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &logs)
if err != nil { if err != nil {
return nil, pagination, err return nil, pagination, err
} }

View File

@@ -11,8 +11,7 @@ import (
"encoding/base64" "encoding/base64"
"fmt" "fmt"
"log/slog" "log/slog"
"os" "path"
"path/filepath"
"time" "time"
"github.com/fxamacker/cbor/v2" "github.com/fxamacker/cbor/v2"
@@ -25,6 +24,7 @@ import (
"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"
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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
@@ -35,15 +35,17 @@ type TestService struct {
jwtService *JwtService jwtService *JwtService
appConfigService *AppConfigService appConfigService *AppConfigService
ldapService *LdapService ldapService *LdapService
fileStorage storage.FileStorage
externalIdPKey jwk.Key externalIdPKey jwk.Key
} }
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) (*TestService, error) { func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{ s := &TestService{
db: db, db: db,
appConfigService: appConfigService, appConfigService: appConfigService,
jwtService: jwtService, jwtService: jwtService,
ldapService: ldapService, ldapService: ldapService,
fileStorage: fileStorage,
} }
err := s.initExternalIdP() err := s.initExternalIdP()
if err != nil { if err != nil {
@@ -79,7 +81,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
}, },
Username: "tim", Username: "tim",
Email: "tim.cook@test.com", Email: utils.Ptr("tim.cook@test.com"),
FirstName: "Tim", FirstName: "Tim",
LastName: "Cook", LastName: "Cook",
DisplayName: "Tim Cook", DisplayName: "Tim Cook",
@@ -90,7 +92,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
}, },
Username: "craig", Username: "craig",
Email: "craig.federighi@test.com", Email: utils.Ptr("craig.federighi@test.com"),
FirstName: "Craig", FirstName: "Craig",
LastName: "Federighi", LastName: "Federighi",
DisplayName: "Craig Federighi", DisplayName: "Craig Federighi",
@@ -424,8 +426,8 @@ func (s *TestService) ResetDatabase() error {
} }
func (s *TestService) ResetApplicationImages(ctx context.Context) error { func (s *TestService) ResetApplicationImages(ctx context.Context) error {
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil { if err := s.fileStorage.DeleteAll(ctx, "/"); err != nil {
slog.ErrorContext(ctx, "Error removing directory", slog.Any("error", err)) slog.ErrorContext(ctx, "Error removing uploads", slog.Any("error", err))
return err return err
} }
@@ -435,13 +437,19 @@ func (s *TestService) ResetApplicationImages(ctx context.Context) error {
} }
for _, file := range files { for _, file := range files {
srcFilePath := filepath.Join("images", file.Name()) if file.IsDir() {
destFilePath := filepath.Join(common.EnvConfig.UploadPath, "application-images", file.Name()) continue
}
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath) srcFilePath := path.Join("images", file.Name())
srcFile, err := resources.FS.Open(srcFilePath)
if err != nil { if err != nil {
return err return err
} }
if err := s.fileStorage.Save(ctx, path.Join("application-images", file.Name()), srcFile); err != nil {
srcFile.Close()
return err
}
srcFile.Close()
} }
return nil return nil

View File

@@ -62,9 +62,13 @@ func (srv *EmailService) SendTestEmail(ctx context.Context, recipientUserId stri
return err return err
} }
if user.Email == nil {
return &common.UserEmailNotSetError{}
}
return SendEmail(ctx, srv, return SendEmail(ctx, srv,
email.Address{ email.Address{
Email: user.Email, Email: *user.Email,
Name: user.FullName(), Name: user.FullName(),
}, TestTemplate, nil) }, TestTemplate, nil)
} }
@@ -278,16 +282,18 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
var htmlHeader = textproto.MIMEHeader{} var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html; charset=UTF-8") htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "8bit") htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
htmlPart, err := mpart.CreatePart(htmlHeader) htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil { if err != nil {
return "", "", fmt.Errorf("create html part: %w", err) return "", "", fmt.Errorf("create html part: %w", err)
} }
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlPart, "root", data) htmlQp := quotedprintable.NewWriter(htmlPart)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
if err != nil { if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err) return "", "", fmt.Errorf("execute html template: %w", err)
} }
htmlQp.Close()
err = mpart.Close() err = mpart.Close()
if err != nil { if err != nil {

View File

@@ -13,11 +13,11 @@ import (
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
"github.com/oschwald/maxminddb-golang/v2" "github.com/oschwald/maxminddb-golang/v2"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
) )
@@ -26,22 +26,6 @@ type GeoLiteService struct {
httpClient *http.Client httpClient *http.Client
disableUpdater bool disableUpdater bool
mutex sync.RWMutex mutex sync.RWMutex
localIPv6Ranges []*net.IPNet
}
var localhostIPNets = []*net.IPNet{
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
{IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128)}, // ::1/128
}
var privateLanIPNets = []*net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
}
var tailscaleIPNets = []*net.IPNet{
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10
} }
// NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database. // NewGeoLiteService initializes a new GeoLiteService instance and starts a goroutine to update the GeoLite2 City database.
@@ -56,67 +40,9 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
service.disableUpdater = true service.disableUpdater = true
} }
// Initialize IPv6 local ranges
err := service.initializeIPv6LocalRanges()
if err != nil {
slog.Warn("Failed to initialize IPv6 local ranges", slog.Any("error", err))
}
return service return service
} }
// initializeIPv6LocalRanges parses the LOCAL_IPV6_RANGES environment variable
func (s *GeoLiteService) initializeIPv6LocalRanges() error {
rangesEnv := common.EnvConfig.LocalIPv6Ranges
if rangesEnv == "" {
return nil // No local IPv6 ranges configured
}
ranges := strings.Split(rangesEnv, ",")
localRanges := make([]*net.IPNet, 0, len(ranges))
for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(rangeStr)
if err != nil {
return fmt.Errorf("invalid IPv6 range '%s': %w", rangeStr, err)
}
// Ensure it's an IPv6 range
if ipNet.IP.To4() != nil {
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
}
localRanges = append(localRanges, ipNet)
}
s.localIPv6Ranges = localRanges
if len(localRanges) > 0 {
slog.Info("Initialized IPv6 local ranges", slog.Int("count", len(localRanges)))
}
return nil
}
// isLocalIPv6 checks if the given IPv6 address is within any of the configured local ranges
func (s *GeoLiteService) isLocalIPv6(ip net.IP) bool {
if ip.To4() != nil {
return false // Not an IPv6 address
}
for _, localRange := range s.localIPv6Ranges {
if localRange.Contains(ip) {
return true
}
}
return false
}
func (s *GeoLiteService) DisableUpdater() bool { func (s *GeoLiteService) DisableUpdater() bool {
return s.disableUpdater return s.disableUpdater
} }
@@ -129,28 +55,19 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
// Check the IP address against known private IP ranges // Check the IP address against known private IP ranges
if ip := net.ParseIP(ipAddress); ip != nil { if ip := net.ParseIP(ipAddress); ip != nil {
// Check IPv6 local ranges first if utils.IsLocalIPv6(ip) {
if s.isLocalIPv6(ip) {
return "Internal Network", "LAN", nil return "Internal Network", "LAN", nil
} }
if utils.IsTailscaleIP(ip) {
// Check existing IPv4 ranges
for _, ipNet := range tailscaleIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "Tailscale", nil return "Internal Network", "Tailscale", nil
} }
} if utils.IsPrivateIP(ip) {
for _, ipNet := range privateLanIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "LAN", nil return "Internal Network", "LAN", nil
} }
} if utils.IsLocalhostIP(ip) {
for _, ipNet := range localhostIPNets {
if ipNet.Contains(ip) {
return "Internal Network", "localhost", nil return "Internal Network", "localhost", nil
} }
} }
}
addr, err := netip.ParseAddr(ipAddress) addr, err := netip.ParseAddr(ipAddress)
if err != nil { if err != nil {

View File

@@ -1,220 +0,0 @@
package service
import (
"net"
"net/http"
"testing"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
tests := []struct {
name string
localRanges string
testIP string
expectedCountry string
expectedCity string
expectError bool
}{
{
name: "IPv6 in local range",
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
testIP: "2001:0db8:abcd:000::1",
expectedCountry: "Internal Network",
expectedCity: "LAN",
expectError: false,
},
{
name: "IPv6 not in local range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:ffff:000::1",
expectError: true,
},
{
name: "Multiple ranges - second range match",
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
testIP: "2001:0db8:abcd:001::1",
expectedCountry: "Internal Network",
expectedCity: "LAN",
expectError: false,
},
{
name: "Empty local ranges",
localRanges: "",
testIP: "2001:0db8:abcd:000::1",
expectError: true,
},
{
name: "IPv4 private address still works",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "192.168.1.1",
expectedCountry: "Internal Network",
expectedCity: "LAN",
expectError: false,
},
{
name: "IPv6 loopback",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "::1",
expectedCountry: "Internal Network",
expectedCity: "localhost",
expectError: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalConfig := common.EnvConfig.LocalIPv6Ranges
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
defer func() {
common.EnvConfig.LocalIPv6Ranges = originalConfig
}()
service := NewGeoLiteService(&http.Client{})
country, city, err := service.GetLocationByIP(tt.testIP)
if tt.expectError {
if err == nil && country != "Internal Network" {
t.Errorf("Expected error or internal network classification for external IP")
}
} else {
require.NoError(t, err)
assert.Equal(t, tt.expectedCountry, country)
assert.Equal(t, tt.expectedCity, city)
}
})
}
}
func TestGeoLiteService_isLocalIPv6(t *testing.T) {
tests := []struct {
name string
localRanges string
testIP string
expected bool
}{
{
name: "Valid IPv6 in range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:abcd:000::1",
expected: true,
},
{
name: "Valid IPv6 not in range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:ffff:000::1",
expected: false,
},
{
name: "IPv4 address should return false",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "192.168.1.1",
expected: false,
},
{
name: "No ranges configured",
localRanges: "",
testIP: "2001:0db8:abcd:000::1",
expected: false,
},
{
name: "Edge of range",
localRanges: "2001:0db8:abcd:000::/56",
testIP: "2001:0db8:abcd:00ff:ffff:ffff:ffff:ffff",
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalConfig := common.EnvConfig.LocalIPv6Ranges
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
defer func() {
common.EnvConfig.LocalIPv6Ranges = originalConfig
}()
service := NewGeoLiteService(&http.Client{})
ip := net.ParseIP(tt.testIP)
if ip == nil {
t.Fatalf("Invalid test IP: %s", tt.testIP)
}
result := service.isLocalIPv6(ip)
assert.Equal(t, tt.expected, result)
})
}
}
func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
tests := []struct {
name string
envValue string
expectError bool
expectCount int
}{
{
name: "Valid IPv6 ranges",
envValue: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
expectError: false,
expectCount: 2,
},
{
name: "Empty environment variable",
envValue: "",
expectError: false,
expectCount: 0,
},
{
name: "Invalid CIDR notation",
envValue: "2001:0db8:abcd:000::/999",
expectError: true,
expectCount: 0,
},
{
name: "IPv4 range in IPv6 env var",
envValue: "192.168.1.0/24",
expectError: true,
expectCount: 0,
},
{
name: "Mixed valid and invalid ranges",
envValue: "2001:0db8:abcd:000::/56,invalid-range",
expectError: true,
expectCount: 0,
},
{
name: "Whitespace handling",
envValue: " 2001:0db8:abcd:000::/56 , 2001:0db8:abcd:001::/56 ",
expectError: false,
expectCount: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
originalConfig := common.EnvConfig.LocalIPv6Ranges
common.EnvConfig.LocalIPv6Ranges = tt.envValue
defer func() {
common.EnvConfig.LocalIPv6Ranges = originalConfig
}()
service := &GeoLiteService{
httpClient: &http.Client{},
}
err := service.initializeIPv6LocalRanges()
if tt.expectError {
require.Error(t, err)
} else {
require.NoError(t, err)
}
assert.Len(t, service.localIPv6Ranges, tt.expectCount)
})
}
}

View File

@@ -16,6 +16,7 @@ import (
"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"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -342,7 +343,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "user123", ID: "user123",
}, },
Email: "user@example.com", Email: utils.Ptr("user@example.com"),
IsAdmin: false, IsAdmin: false,
} }
@@ -385,7 +386,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "admin123", ID: "admin123",
}, },
Email: "admin@example.com", Email: utils.Ptr("admin@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -464,7 +465,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "eddsauser123", ID: "eddsauser123",
}, },
Email: "eddsauser@example.com", Email: utils.Ptr("eddsauser@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -521,7 +522,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "ecdsauser123", ID: "ecdsauser123",
}, },
Email: "ecdsauser@example.com", Email: utils.Ptr("ecdsauser@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -578,7 +579,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "rsauser123", ID: "rsauser123",
}, },
Email: "rsauser@example.com", Email: utils.Ptr("rsauser@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -965,7 +966,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "user123", ID: "user123",
}, },
Email: "user@example.com", Email: utils.Ptr("user@example.com"),
} }
const clientID = "test-client-123" const clientID = "test-client-123"
@@ -1092,7 +1093,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "eddsauser789", ID: "eddsauser789",
}, },
Email: "eddsaoauth@example.com", Email: utils.Ptr("eddsaoauth@example.com"),
} }
const clientID = "eddsa-oauth-client" const clientID = "eddsa-oauth-client"
@@ -1149,7 +1150,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "ecdsauser789", ID: "ecdsauser789",
}, },
Email: "ecdsaoauth@example.com", Email: utils.Ptr("ecdsaoauth@example.com"),
} }
const clientID = "ecdsa-oauth-client" const clientID = "ecdsa-oauth-client"
@@ -1206,7 +1207,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
Base: model.Base{ Base: model.Base{
ID: "rsauser789", ID: "rsauser789",
}, },
Email: "rsaoauth@example.com", Email: utils.Ptr("rsaoauth@example.com"),
} }
const clientID = "rsa-oauth-client" const clientID = "rsa-oauth-client"

View File

@@ -17,6 +17,7 @@ import (
"github.com/go-ldap/ldap/v3" "github.com/go-ldap/ldap/v3"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
"gorm.io/gorm" "gorm.io/gorm"
@@ -348,13 +349,18 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value), Email: utils.PtrOrNil(value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value)),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
IsAdmin: isAdmin, IsAdmin: isAdmin,
LdapID: ldapId, LdapID: ldapId,
} }
if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
}
dto.Normalize(newUser) dto.Normalize(newUser)
err = newUser.Validate() err = newUser.Validate()
@@ -464,7 +470,7 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
} }
// Update the profile picture // Update the profile picture
err = s.userService.UpdateProfilePicture(userId, reader) err = s.userService.UpdateProfilePicture(parentCtx, userId, reader)
if err != nil { if err != nil {
return fmt.Errorf("failed to update profile picture: %w", err) return fmt.Errorf("failed to update profile picture: %w", err)
} }

View File

@@ -3,15 +3,19 @@ package service
import ( import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"crypto/subtle"
"crypto/tls" "crypto/tls"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"mime/multipart" "mime/multipart"
"net"
"net/http" "net/http"
"os" "net/url"
"path"
"regexp" "regexp"
"slices" "slices"
"strings" "strings"
@@ -30,6 +34,7 @@ import (
"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"
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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )
@@ -56,6 +61,7 @@ type OidcService struct {
httpClient *http.Client httpClient *http.Client
jwkCache *jwk.Cache jwkCache *jwk.Cache
fileStorage storage.FileStorage
} }
func NewOidcService( func NewOidcService(
@@ -66,6 +72,8 @@ func NewOidcService(
auditLogService *AuditLogService, auditLogService *AuditLogService,
customClaimService *CustomClaimService, customClaimService *CustomClaimService,
webAuthnService *WebAuthnService, webAuthnService *WebAuthnService,
httpClient *http.Client,
fileStorage storage.FileStorage,
) (s *OidcService, err error) { ) (s *OidcService, err error) {
s = &OidcService{ s = &OidcService{
db: db, db: db,
@@ -74,6 +82,8 @@ func NewOidcService(
auditLogService: auditLogService, auditLogService: auditLogService,
customClaimService: customClaimService, customClaimService: customClaimService,
webAuthnService: webAuthnService, webAuthnService: webAuthnService,
httpClient: httpClient,
fileStorage: fileStorage,
} }
// 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
@@ -388,7 +398,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
// If the client is public or PKCE is enabled, the code verifier must match the code challenge // If the client is public or PKCE is enabled, the code verifier must match the code challenge
if client.IsPublic || client.PkceEnabled { if client.IsPublic || client.PkceEnabled {
if !s.validateCodeVerifier(input.CodeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) { if !validateCodeVerifier(input.CodeVerifier, *authorizationCodeMetaData.CodeChallenge, *authorizationCodeMetaData.CodeChallengeMethodSha256) {
return CreatedTokens{}, &common.OidcInvalidCodeVerifierError{} return CreatedTokens{}, &common.OidcInvalidCodeVerifierError{}
} }
} }
@@ -469,7 +479,7 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
var storedRefreshToken model.OidcRefreshToken var storedRefreshToken model.OidcRefreshToken
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Preload("User"). Preload("User.UserGroups").
Where( Where(
"token = ? AND expires_at > ? AND user_id = ? AND client_id = ?", "token = ? AND expires_at > ? AND user_id = ? AND client_id = ?",
utils.CreateSha256Hash(rt), utils.CreateSha256Hash(rt),
@@ -686,7 +696,7 @@ func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx
return client, nil return client, nil
} }
func (s *OidcService) ListClients(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.OidcClient, utils.PaginationResponse, error) { func (s *OidcService) ListClients(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) ([]model.OidcClient, utils.PaginationResponse, error) {
var clients []model.OidcClient var clients []model.OidcClient
query := s.db. query := s.db.
@@ -699,21 +709,26 @@ 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
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) { if listRequestOptions.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
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").
Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + sortedPaginationRequest.Sort.Direction) Order("COUNT(oidc_clients_allowed_user_groups.oidc_client_id) " + listRequestOptions.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &clients) response, err := utils.Paginate(listRequestOptions.Pagination.Page, listRequestOptions.Pagination.Limit, query, &clients)
return clients, response, err return clients, response, err
} }
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &clients) response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
return clients, response, err return clients, response, err
} }
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) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client := model.OidcClient{ client := model.OidcClient{
Base: model.Base{ Base: model.Base{
ID: input.ID, ID: input.ID,
@@ -722,7 +737,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
} }
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto) updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
err := s.db. err := tx.
WithContext(ctx). WithContext(ctx).
Create(&client). Create(&client).
Error Error
@@ -733,33 +748,18 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
return model.OidcClient{}, err return model.OidcClient{}, err
} }
return client, nil if input.LogoURL != nil {
err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL, true)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
}
} }
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) { if input.DarkLogoURL != nil {
tx := s.db.Begin() err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.DarkLogoURL, false)
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.
WithContext(ctx).
Preload("CreatedBy").
First(&client, "id = ?", clientID).
Error
if err != nil { if err != nil {
return model.OidcClient{}, err return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err)
} }
updateOIDCClientModelFromDto(&client, &input)
err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return model.OidcClient{}, err
} }
err = tx.Commit().Error err = tx.Commit().Error
@@ -770,6 +770,43 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
return client, nil return client, nil
} }
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
tx := s.db.Begin()
defer func() { tx.Rollback() }()
var client model.OidcClient
if err := tx.WithContext(ctx).
Preload("CreatedBy").
First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
updateOIDCClientModelFromDto(&client, &input)
if err := tx.WithContext(ctx).Save(&client).Error; err != nil {
return model.OidcClient{}, err
}
if input.LogoURL != nil {
err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL, true)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
}
}
if input.DarkLogoURL != nil {
err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.DarkLogoURL, false)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err)
}
}
if err := tx.Commit().Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) { func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
// Base fields // Base fields
client.Name = input.Name client.Name = input.Name
@@ -850,74 +887,73 @@ func (s *OidcService) CreateClientSecret(ctx context.Context, clientID string) (
return clientSecret, nil return clientSecret, nil
} }
func (s *OidcService) GetClientLogo(ctx context.Context, clientID string) (string, string, error) { func (s *OidcService) GetClientLogo(ctx context.Context, clientID string, light bool) (io.ReadCloser, int64, string, error) {
var client model.OidcClient var client model.OidcClient
err := s.db. err := s.db.
WithContext(ctx). WithContext(ctx).
First(&client, "id = ?", clientID). First(&client, "id = ?", clientID).
Error Error
if err != nil { if err != nil {
return "", "", err return nil, 0, "", err
} }
if client.ImageType == nil { var suffix string
return "", "", errors.New("image not found") var ext string
switch {
case !light && client.DarkImageType != nil:
// Dark logo if requested and exists
suffix = "-dark"
ext = *client.DarkImageType
case client.ImageType != nil:
// Light logo if requested or no dark logo is available
ext = *client.ImageType
default:
return nil, 0, "", errors.New("image not found")
} }
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + *client.ImageType mimeType := utils.GetImageMimeType(ext)
mimeType := utils.GetImageMimeType(*client.ImageType) if mimeType == "" {
return nil, 0, "", fmt.Errorf("unsupported image type '%s'", ext)
return imagePath, mimeType, nil }
key := path.Join("oidc-client-images", client.ID+suffix+"."+ext)
reader, size, err := s.fileStorage.Open(ctx, key)
if err != nil {
return nil, 0, "", err
} }
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader) error { return reader, size, mimeType, nil
}
func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, file *multipart.FileHeader, light bool) error {
fileType := strings.ToLower(utils.GetFileExtension(file.Filename)) fileType := strings.ToLower(utils.GetFileExtension(file.Filename))
if mimeType := utils.GetImageMimeType(fileType); mimeType == "" { if mimeType := utils.GetImageMimeType(fileType); mimeType == "" {
return &common.FileTypeNotSupportedError{} return &common.FileTypeNotSupportedError{}
} }
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + clientID + "." + fileType var darkSuffix string
err := utils.SaveFile(file, imagePath) if !light {
darkSuffix = "-dark"
}
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+fileType)
reader, err := file.Open()
if err != nil { if err != nil {
return err return err
} }
defer reader.Close()
if err := s.fileStorage.Save(ctx, imagePath, reader); err != nil {
return err
}
tx := s.db.Begin() tx := s.db.Begin()
defer func() {
err = s.updateClientLogoType(ctx, tx, clientID, fileType, light)
if err != nil {
tx.Rollback() tx.Rollback()
}()
var client model.OidcClient
err = tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return err return err
} }
if client.ImageType != nil && fileType != *client.ImageType { return tx.Commit().Error
oldImagePath := fmt.Sprintf("%s/oidc-client-images/%s.%s", common.EnvConfig.UploadPath, client.ID, *client.ImageType)
if err := os.Remove(oldImagePath); err != nil {
return err
}
}
client.ImageType = &fileType
err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
return nil
} }
func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error { func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error {
@@ -941,6 +977,7 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
oldImageType := *client.ImageType oldImageType := *client.ImageType
client.ImageType = nil client.ImageType = nil
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Save(&client). Save(&client).
@@ -949,8 +986,51 @@ func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) err
return err return err
} }
imagePath := common.EnvConfig.UploadPath + "/oidc-client-images/" + client.ID + "." + oldImageType imagePath := path.Join("oidc-client-images", client.ID+"."+oldImageType)
if err := os.Remove(imagePath); err != nil { if err := s.fileStorage.Delete(ctx, imagePath); err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
return nil
}
func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return err
}
if client.DarkImageType == nil {
return errors.New("image not found")
}
oldImageType := *client.DarkImageType
client.DarkImageType = nil
err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return err
}
imagePath := path.Join("oidc-client-images", client.ID+"-dark."+oldImageType)
if err := s.fileStorage.Delete(ctx, imagePath); err != nil {
return err return err
} }
@@ -1090,13 +1170,20 @@ func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID stri
return randomString, nil return randomString, nil
} }
func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool { func validateCodeVerifier(codeVerifier, codeChallenge string, codeChallengeMethodSha256 bool) bool {
if codeVerifier == "" || codeChallenge == "" { if codeVerifier == "" || codeChallenge == "" {
return false return false
} }
if !codeChallengeMethodSha256 { if !codeChallengeMethodSha256 {
return codeVerifier == codeChallenge return subtle.ConstantTimeCompare([]byte(codeVerifier), []byte(codeChallenge)) == 1
}
// Base64 URL decode the challenge
// If it's not valid base64url, fail the operation
codeChallengeBytes, err := base64.RawURLEncoding.DecodeString(codeChallenge)
if err != nil {
return false
} }
// Compute SHA-256 hash of the codeVerifier // Compute SHA-256 hash of the codeVerifier
@@ -1104,10 +1191,7 @@ func (s *OidcService) validateCodeVerifier(codeVerifier, codeChallenge string, c
h.Write([]byte(codeVerifier)) h.Write([]byte(codeVerifier))
codeVerifierHash := h.Sum(nil) codeVerifierHash := h.Sum(nil)
// Base64 URL encode the verifier hash return subtle.ConstantTimeCompare(codeVerifierHash, codeChallengeBytes) == 1
encodedVerifierHash := base64.RawURLEncoding.EncodeToString(codeVerifierHash)
return encodedVerifierHash == codeChallenge
} }
func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) { func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL string, tx *gorm.DB, ctx context.Context) (callbackURL string, err error) {
@@ -1333,7 +1417,8 @@ func (s *OidcService) GetDeviceCodeInfo(ctx context.Context, userCode string, us
Client: dto.OidcClientMetaDataDto{ Client: dto.OidcClientMetaDataDto{
ID: deviceAuth.Client.ID, ID: deviceAuth.Client.ID,
Name: deviceAuth.Client.Name, Name: deviceAuth.Client.Name,
HasLogo: deviceAuth.Client.HasLogo, HasLogo: deviceAuth.Client.HasLogo(),
HasDarkLogo: deviceAuth.Client.HasDarkLogo(),
}, },
Scope: deviceAuth.Scope, Scope: deviceAuth.Scope,
AuthorizationRequired: !hasAuthorizedClient, AuthorizationRequired: !hasAuthorizedClient,
@@ -1357,7 +1442,7 @@ func (s *OidcService) GetAllowedGroupsCountOfClient(ctx context.Context, id stri
return count, nil return count, nil
} }
func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) { func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.UserAuthorizedOidcClient, utils.PaginationResponse, error) {
query := s.db. query := s.db.
WithContext(ctx). WithContext(ctx).
@@ -1366,7 +1451,7 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
Where("user_id = ?", userID) Where("user_id = ?", userID)
var authorizedClients []model.UserAuthorizedOidcClient var authorizedClients []model.UserAuthorizedOidcClient
response, err := utils.PaginateAndSort(sortedPaginationRequest, query, &authorizedClients) response, err := utils.PaginateFilterAndSort(listRequestOptions, query, &authorizedClients)
return authorizedClients, response, err return authorizedClients, response, err
} }
@@ -1399,7 +1484,7 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
return nil return nil
} }
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) { func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
tx.Rollback() tx.Rollback()
@@ -1446,13 +1531,13 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
// Handle custom sorting for lastUsedAt column // Handle custom sorting for lastUsedAt column
var response utils.PaginationResponse var response utils.PaginationResponse
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) { if listRequestOptions.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
query = query. 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). 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") Order("user_authorized_oidc_clients.last_used_at " + listRequestOptions.Sort.Direction + " NULLS LAST")
} }
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients) response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &clients)
if err != nil { if err != nil {
return nil, utils.PaginationResponse{}, err return nil, utils.PaginationResponse{}, err
} }
@@ -1468,7 +1553,8 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
ID: client.ID, ID: client.ID,
Name: client.Name, Name: client.Name,
LaunchURL: client.LaunchURL, LaunchURL: client.LaunchURL,
HasLogo: client.HasLogo, HasLogo: client.HasLogo(),
HasDarkLogo: client.HasDarkLogo(),
}, },
LastUsedAt: lastUsedAt, LastUsedAt: lastUsedAt,
} }
@@ -1889,3 +1975,110 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
return s.IsUserGroupAllowedToAuthorize(user, client), nil return s.IsUserGroupAllowedToAuthorize(user, client), nil
} }
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx *gorm.DB, clientID string, raw string, light bool) error {
u, err := url.Parse(raw)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
defer cancel()
r := net.Resolver{}
ips, err := r.LookupIPAddr(ctx, u.Hostname())
if err != nil || len(ips) == 0 {
return fmt.Errorf("cannot resolve hostname")
}
// Prevents SSRF by allowing only public IPs
for _, addr := range ips {
if utils.IsPrivateIP(addr.IP) {
return fmt.Errorf("private IP addresses are not allowed")
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
return err
}
req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher")
req.Header.Set("Accept", "image/*")
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("failed to fetch logo: %s", resp.Status)
}
const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB
if resp.ContentLength > maxLogoSize {
return fmt.Errorf("logo is too large")
}
// Prefer extension in path if supported
ext := utils.GetFileExtension(u.Path)
if ext == "" || utils.GetImageMimeType(ext) == "" {
// Otherwise, try to detect from content type
ext = utils.GetImageExtensionFromMimeType(resp.Header.Get("Content-Type"))
}
if ext == "" {
return &common.FileTypeNotSupportedError{}
}
var darkSuffix string
if !light {
darkSuffix = "-dark"
}
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+ext)
if err := s.fileStorage.Save(ctx, imagePath, io.LimitReader(resp.Body, maxLogoSize+1)); err != nil {
return err
}
if err := s.updateClientLogoType(ctx, tx, clientID, ext, light); err != nil {
return err
}
return nil
}
func (s *OidcService) updateClientLogoType(ctx context.Context, tx *gorm.DB, clientID, ext string, light bool) error {
var darkSuffix string
if !light {
darkSuffix = "-dark"
}
var client model.OidcClient
if err := tx.WithContext(ctx).First(&client, "id = ?", clientID).Error; err != nil {
return err
}
var currentType *string
if light {
currentType = client.ImageType
} else {
currentType = client.DarkImageType
}
if currentType != nil && *currentType != ext {
old := path.Join("oidc-client-images", client.ID+darkSuffix+"."+*currentType)
_ = s.fileStorage.Delete(ctx, old)
}
var column string
if light {
column = "image_type"
} else {
column = "dark_image_type"
}
return tx.WithContext(ctx).
Model(&model.OidcClient{}).
Where("id = ?", clientID).
Update(column, ext).
Error
}

View File

@@ -5,6 +5,8 @@ import (
"crypto/ecdsa" "crypto/ecdsa"
"crypto/elliptic" "crypto/elliptic"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json" "encoding/json"
"net/http" "net/http"
"testing" "testing"
@@ -510,3 +512,28 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
}) })
}) })
} }
func TestValidateCodeVerifier_Plain(t *testing.T) {
require.False(t, validateCodeVerifier("", "", false))
require.False(t, validateCodeVerifier("", "", true))
t.Run("plain", func(t *testing.T) {
require.False(t, validateCodeVerifier("", "challenge", false))
require.False(t, validateCodeVerifier("verifier", "", false))
require.True(t, validateCodeVerifier("plainVerifier", "plainVerifier", false))
require.False(t, validateCodeVerifier("plainVerifier", "otherVerifier", false))
})
t.Run("SHA 256", func(t *testing.T) {
codeVerifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
hash := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(hash[:])
require.True(t, validateCodeVerifier(codeVerifier, codeChallenge, true))
require.False(t, validateCodeVerifier("wrongVerifier", codeChallenge, true))
require.False(t, validateCodeVerifier(codeVerifier, "!", true))
// Invalid base64
require.False(t, validateCodeVerifier("NOT!VALID", codeChallenge, true))
})
}

View File

@@ -21,7 +21,7 @@ func NewUserGroupService(db *gorm.DB, appConfigService *AppConfigService) *UserG
return &UserGroupService{db: db, appConfigService: appConfigService} return &UserGroupService{db: db, appConfigService: appConfigService}
} }
func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginationRequest utils.SortedPaginationRequest) (groups []model.UserGroup, response utils.PaginationResponse, err error) { func (s *UserGroupService) List(ctx context.Context, name string, listRequestOptions utils.ListRequestOptions) (groups []model.UserGroup, response utils.PaginationResponse, err error) {
query := s.db. query := s.db.
WithContext(ctx). WithContext(ctx).
Preload("CustomClaims"). Preload("CustomClaims").
@@ -32,17 +32,14 @@ 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
if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) { if listRequestOptions.Sort.Column == "userCount" && utils.IsValidSortDirection(listRequestOptions.Sort.Direction) {
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").
Order("COUNT(user_groups_users.user_id) " + sortedPaginationRequest.Sort.Direction) Order("COUNT(user_groups_users.user_id) " + listRequestOptions.Sort.Direction)
response, err := utils.Paginate(sortedPaginationRequest.Pagination.Page, sortedPaginationRequest.Pagination.Limit, query, &groups)
return groups, response, err
} }
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &groups) response, err = utils.PaginateFilterAndSort(listRequestOptions, query, &groups)
return groups, response, err return groups, response, err
} }

View File

@@ -7,9 +7,10 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"net/url" "net/url"
"os" "path"
"strings" "strings"
"time" "time"
@@ -21,6 +22,7 @@ import (
"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"
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/storage"
"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"
profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image" profilepicture "github.com/pocket-id/pocket-id/backend/internal/utils/image"
@@ -33,9 +35,11 @@ type UserService struct {
emailService *EmailService emailService *EmailService
appConfigService *AppConfigService appConfigService *AppConfigService
customClaimService *CustomClaimService customClaimService *CustomClaimService
appImagesService *AppImagesService
fileStorage storage.FileStorage
} }
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService { func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService, appImagesService *AppImagesService, fileStorage storage.FileStorage) *UserService {
return &UserService{ return &UserService{
db: db, db: db,
jwtService: jwtService, jwtService: jwtService,
@@ -43,10 +47,12 @@ func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditL
emailService: emailService, emailService: emailService,
appConfigService: appConfigService, appConfigService: appConfigService,
customClaimService: customClaimService, customClaimService: customClaimService,
appImagesService: appImagesService,
fileStorage: fileStorage,
} }
} }
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, listRequestOptions utils.ListRequestOptions) ([]model.User, utils.PaginationResponse, error) {
var users []model.User var users []model.User
query := s.db.WithContext(ctx). query := s.db.WithContext(ctx).
Model(&model.User{}). Model(&model.User{}).
@@ -60,7 +66,7 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
searchPattern, searchPattern, searchPattern, searchPattern) searchPattern, searchPattern, searchPattern, searchPattern)
} }
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &users) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &users)
return users, pagination, err return users, pagination, err
} }
@@ -87,60 +93,56 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
return nil, 0, &common.InvalidUUIDError{} return nil, 0, &common.InvalidUUIDError{}
} }
// First check for a custom uploaded profile picture (userID.png)
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png"
file, err := os.Open(profilePicturePath)
if err == nil {
// Get the file size
fileInfo, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, err
}
return file, fileInfo.Size(), nil
}
// If no custom picture exists, get the user's data for creating initials
user, err := s.GetUser(ctx, userID) user, err := s.GetUser(ctx, userID)
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
// Check if we have a cached default picture for these initials profilePicturePath := path.Join("profile-pictures", userID+".png")
defaultProfilePicturesDir := common.EnvConfig.UploadPath + "/profile-pictures/defaults/"
defaultPicturePath := defaultProfilePicturesDir + user.Initials() + ".png" // Try custom profile picture
file, err = os.Open(defaultPicturePath) if file, size, err := s.fileStorage.Open(ctx, profilePicturePath); err == nil {
if err == nil { return file, size, nil
fileInfo, err := file.Stat() } else if err != nil && !errors.Is(err, fs.ErrNotExist) {
if err != nil { return nil, 0, err
file.Close() }
// Try default global profile picture
if s.appImagesService.IsDefaultProfilePictureSet() {
reader, size, _, err := s.appImagesService.GetImage(ctx, "default-profile-picture")
if err == nil {
return reader, size, nil
}
if !errors.Is(err, &common.ImageNotFoundError{}) {
return nil, 0, err return nil, 0, err
} }
return file, fileInfo.Size(), nil
} }
// If no cached default picture exists, create one and save it for future use // Try cached default for initials
defaultPicturePath := path.Join("profile-pictures", "defaults", user.Initials()+".png")
if file, size, err := s.fileStorage.Open(ctx, defaultPicturePath); err == nil {
return file, size, nil
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, 0, err
}
// Create and return generated default with initials
defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials()) defaultPicture, err := profilepicture.CreateDefaultProfilePicture(user.Initials())
if err != nil { if err != nil {
return nil, 0, err return nil, 0, err
} }
// Save the default picture for future use (in a goroutine to avoid blocking) // Save the default picture for future use (in a goroutine to avoid blocking)
//nolint:contextcheck
defaultPictureBytes := defaultPicture.Bytes() defaultPictureBytes := defaultPicture.Bytes()
//nolint:contextcheck
go func() { go func() {
// Ensure the directory exists if err := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes)); err != nil {
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm) slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", err))
if errInternal != nil {
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
return
}
errInternal = utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath)
if errInternal != nil {
slog.Error("Failed to cache default profile picture for initials", slog.String("initials", user.Initials()), slog.Any("error", errInternal))
} }
}() }()
return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(defaultPicture.Len()), nil return io.NopCloser(bytes.NewReader(defaultPictureBytes)), int64(len(defaultPictureBytes)), nil
} }
func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) { func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model.UserGroup, error) {
@@ -157,7 +159,7 @@ func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model
return user.UserGroups, nil return user.UserGroups, nil
} }
func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error { func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, file io.Reader) error {
// Validate the user ID to prevent directory traversal // Validate the user ID to prevent directory traversal
err := uuid.Validate(userID) err := uuid.Validate(userID)
if err != nil { if err != nil {
@@ -170,15 +172,8 @@ func (s *UserService) UpdateProfilePicture(userID string, file io.Reader) error
return err return err
} }
// Ensure the directory exists profilePicturePath := path.Join("profile-pictures", userID+".png")
profilePictureDir := common.EnvConfig.UploadPath + "/profile-pictures" err = s.fileStorage.Save(ctx, profilePicturePath, profilePicture)
err = os.MkdirAll(profilePictureDir, os.ModePerm)
if err != nil {
return err
}
// Create the profile picture file
err = utils.SaveFileStream(profilePicture, profilePictureDir+"/"+userID+".png")
if err != nil { if err != nil {
return err return err
} }
@@ -209,10 +204,8 @@ func (s *UserService) deleteUserInternal(ctx context.Context, userID string, all
return &common.LdapUserUpdateError{} return &common.LdapUserUpdateError{}
} }
// Delete the profile picture profilePicturePath := path.Join("profile-pictures", userID+".png")
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png" if err := s.fileStorage.Delete(ctx, profilePicturePath); err != nil {
err = os.Remove(profilePicturePath)
if err != nil && !os.IsNotExist(err) {
return err return err
} }
@@ -244,6 +237,10 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
} }
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && input.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
}
user := model.User{ user := model.User{
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
@@ -252,6 +249,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
Username: input.Username, Username: input.Username,
IsAdmin: input.IsAdmin, IsAdmin: input.IsAdmin,
Locale: input.Locale, Locale: input.Locale,
Disabled: input.Disabled,
} }
if input.LdapID != "" { if input.LdapID != "" {
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
@@ -339,6 +337,10 @@ func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser
} }
func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) { func (s *UserService) updateUserInternal(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool, tx *gorm.DB) (model.User, error) {
if s.appConfigService.GetDbConfig().RequireUserEmail.IsTrue() && updatedUser.Email == nil {
return model.User{}, &common.UserEmailNotSetError{}
}
var user model.User var user model.User
err := tx. err := tx.
WithContext(ctx). WithContext(ctx).
@@ -437,6 +439,10 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
return err return err
} }
if user.Email == nil {
return &common.UserEmailNotSetError{}
}
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx) oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
if err != nil { if err != nil {
return err return err
@@ -464,7 +470,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
errInternal := SendEmail(innerCtx, s.emailService, email.Address{ errInternal := SendEmail(innerCtx, s.emailService, email.Address{
Name: user.FullName(), Name: user.FullName(),
Email: user.Email, Email: *user.Email,
}, OneTimeAccessTemplate, &OneTimeAccessTemplateData{ }, OneTimeAccessTemplate, &OneTimeAccessTemplateData{
Code: oneTimeAccessToken, Code: oneTimeAccessToken,
LoginLink: link, LoginLink: link,
@@ -472,7 +478,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
ExpirationString: utils.DurationToString(ttl), ExpirationString: utils.DurationToString(ttl),
}) })
if errInternal != nil { if errInternal != nil {
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", user.Email)) slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", *user.Email))
return return
} }
}() }()
@@ -660,26 +666,16 @@ func (s *UserService) checkDuplicatedFields(ctx context.Context, user model.User
} }
// ResetProfilePicture deletes a user's custom profile picture // ResetProfilePicture deletes a user's custom profile picture
func (s *UserService) ResetProfilePicture(userID string) error { func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) error {
// Validate the user ID to prevent directory traversal // Validate the user ID to prevent directory traversal
if err := uuid.Validate(userID); err != nil { if err := uuid.Validate(userID); err != nil {
return &common.InvalidUUIDError{} return &common.InvalidUUIDError{}
} }
// Build path to profile picture profilePicturePath := path.Join("profile-pictures", userID+".png")
profilePicturePath := common.EnvConfig.UploadPath + "/profile-pictures/" + userID + ".png" if err := s.fileStorage.Delete(ctx, profilePicturePath); err != nil {
// Check if file exists and delete it
if _, err := os.Stat(profilePicturePath); err == nil {
if err := os.Remove(profilePicturePath); err != nil {
return fmt.Errorf("failed to delete profile picture: %w", err) return fmt.Errorf("failed to delete profile picture: %w", err)
} }
} else if !os.IsNotExist(err) {
// If any error other than "file not exists"
return fmt.Errorf("failed to check if profile picture exists: %w", err)
}
// It's okay if the file doesn't exist - just means there's no custom picture to delete
return nil return nil
} }
@@ -782,11 +778,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
return user, accessToken, nil return user, accessToken, nil
} }
func (s *UserService) ListSignupTokens(ctx context.Context, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.SignupToken, utils.PaginationResponse, error) { func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken var tokens []model.SignupToken
query := s.db.WithContext(ctx).Model(&model.SignupToken{}) query := s.db.WithContext(ctx).Model(&model.SignupToken{})
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &tokens) pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err return tokens, pagination, err
} }

View File

@@ -81,6 +81,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
&user, &user,
webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired), webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()), webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
webauthn.WithExtensions(map[string]any{"credProps": true}), // Required for Firefox Android to properly save the key in Google password manager
) )
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err) return nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
@@ -89,6 +90,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
sessionToStore := &model.WebauthnSession{ sessionToStore := &model.WebauthnSession{
ExpiresAt: datatype.DateTime(session.Expires), ExpiresAt: datatype.DateTime(session.Expires),
Challenge: session.Challenge, Challenge: session.Challenge,
CredentialParams: session.CredParams,
UserVerification: string(session.UserVerification), UserVerification: string(session.UserVerification),
} }
@@ -132,6 +134,7 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
session := webauthn.SessionData{ session := webauthn.SessionData{
Challenge: storedSession.Challenge, Challenge: storedSession.Challenge,
Expires: storedSession.ExpiresAt.ToTime(), Expires: storedSession.ExpiresAt.ToTime(),
CredParams: storedSession.CredentialParams,
UserID: []byte(userID), UserID: []byte(userID),
} }

View File

@@ -0,0 +1,193 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/google/uuid"
)
type filesystemStorage struct {
root *os.Root
absoluteRootPath string
}
func NewFilesystemStorage(rootPath string) (FileStorage, error) {
if err := os.MkdirAll(rootPath, 0700); err != nil {
return nil, fmt.Errorf("failed to create root directory '%s': %w", rootPath, err)
}
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, fmt.Errorf("failed to open root directory '%s': %w", rootPath, err)
}
absoluteRootPath, err := filepath.Abs(rootPath)
if err != nil {
return nil, fmt.Errorf("failed to get absolute path of root directory '%s': %w", rootPath, err)
}
return &filesystemStorage{root: root, absoluteRootPath: absoluteRootPath}, err
}
func (s *filesystemStorage) Type() string {
return TypeFileSystem
}
func (s *filesystemStorage) Save(_ context.Context, path string, data io.Reader) error {
path = filepath.FromSlash(path)
if err := s.root.MkdirAll(filepath.Dir(path), 0700); err != nil {
return fmt.Errorf("failed to create directories for path '%s': %w", path, err)
}
// Our strategy is to save to a separate file and then rename it to override the original file
tmpName := path + "." + uuid.NewString() + "-tmp"
// Write to the temporary file
tmpFile, err := s.root.Create(tmpName)
if err != nil {
return fmt.Errorf("failed to open file '%s' for writing: %w", tmpName, err)
}
_, err = io.Copy(tmpFile, data)
if err != nil {
tmpFile.Close()
_ = s.root.Remove(tmpName)
return fmt.Errorf("failed to write temporary file: %w", err)
}
if err = tmpFile.Close(); err != nil {
_ = s.root.Remove(tmpName)
return fmt.Errorf("failed to close temporary file: %w", err)
}
// Rename to the final file, which overrides existing files
// This is an atomic operation
if err = s.root.Rename(tmpName, path); err != nil {
_ = s.root.Remove(tmpName)
return fmt.Errorf("failed to move temporary file: %w", err)
}
return nil
}
func (s *filesystemStorage) Open(_ context.Context, path string) (io.ReadCloser, int64, error) {
path = filepath.FromSlash(path)
file, err := s.root.Open(path)
if err != nil {
return nil, 0, err
}
info, err := file.Stat()
if err != nil {
file.Close()
return nil, 0, err
}
return file, info.Size(), nil
}
func (s *filesystemStorage) Delete(_ context.Context, path string) error {
path = filepath.FromSlash(path)
err := s.root.Remove(path)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return err
}
return nil
}
func (s *filesystemStorage) DeleteAll(_ context.Context, path string) error {
path = filepath.FromSlash(path)
// If "/", "." or "" is requested, we delete all contents of the root.
if path == "" || path == "/" || path == "." {
dir, err := s.root.Open(".")
if err != nil {
return fmt.Errorf("failed to open root directory: %w", err)
}
defer dir.Close()
entries, err := dir.ReadDir(-1)
if err != nil {
return fmt.Errorf("failed to list root directory: %w", err)
}
for _, entry := range entries {
if err := s.root.RemoveAll(entry.Name()); err != nil {
return fmt.Errorf("failed to delete '%s': %w", entry.Name(), err)
}
}
return nil
}
return s.root.RemoveAll(path)
}
func (s *filesystemStorage) List(_ context.Context, path string) ([]ObjectInfo, error) {
path = filepath.FromSlash(path)
dir, err := s.root.Open(path)
if err != nil {
return nil, err
}
defer dir.Close()
entries, err := dir.ReadDir(-1)
if err != nil {
return nil, err
}
objects := make([]ObjectInfo, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() {
continue
}
info, err := entry.Info()
if err != nil {
return nil, err
}
objects = append(objects, ObjectInfo{
Path: filepath.Join(path, entry.Name()),
Size: info.Size(),
ModTime: info.ModTime(),
})
}
return objects, nil
}
func (s *filesystemStorage) Walk(_ context.Context, root string, fn func(ObjectInfo) error) error {
root = filepath.FromSlash(root)
fullPath := filepath.Clean(filepath.Join(s.absoluteRootPath, root))
// As we can't use os.Root here, we manually ensure that the fullPath is within the root directory
sep := string(filepath.Separator)
if !strings.HasPrefix(fullPath+sep, s.absoluteRootPath+sep) {
return fmt.Errorf("invalid root path: %s", root)
}
return filepath.WalkDir(fullPath, func(full string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
rel, err := filepath.Rel(s.absoluteRootPath, full)
if err != nil {
return err
}
info, err := d.Info()
if err != nil {
return err
}
return fn(ObjectInfo{
Path: filepath.ToSlash(rel),
Size: info.Size(),
ModTime: info.ModTime(),
})
})
}

View File

@@ -0,0 +1,68 @@
package storage
import (
"bytes"
"context"
"io"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestFilesystemStorageOperations(t *testing.T) {
ctx := context.Background()
store, err := NewFilesystemStorage(t.TempDir())
require.NoError(t, err)
t.Run("save, open and list files", func(t *testing.T) {
err := store.Save(ctx, "images/logo.png", bytes.NewBufferString("logo-data"))
require.NoError(t, err)
reader, size, err := store.Open(ctx, "images/logo.png")
require.NoError(t, err)
defer reader.Close()
contents, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, []byte("logo-data"), contents)
assert.Equal(t, int64(len(contents)), size)
err = store.Save(ctx, "images/nested/child.txt", bytes.NewBufferString("child"))
require.NoError(t, err)
files, err := store.List(ctx, "images")
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, filepath.Join("images", "logo.png"), files[0].Path)
assert.Equal(t, int64(len("logo-data")), files[0].Size)
})
t.Run("delete files individually and idempotently", func(t *testing.T) {
err := store.Save(ctx, "images/delete-me.txt", bytes.NewBufferString("temp"))
require.NoError(t, err)
require.NoError(t, store.Delete(ctx, "images/delete-me.txt"))
_, _, err = store.Open(ctx, "images/delete-me.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
// Deleting a missing object should be a no-op.
require.NoError(t, store.Delete(ctx, "images/missing.txt"))
})
t.Run("delete all files under a prefix", func(t *testing.T) {
require.NoError(t, store.Save(ctx, "images/a.txt", bytes.NewBufferString("a")))
require.NoError(t, store.Save(ctx, "images/b.txt", bytes.NewBufferString("b")))
require.NoError(t, store.DeleteAll(ctx, "images"))
_, _, err := store.Open(ctx, "images/a.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
_, _, err = store.Open(ctx, "images/b.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
})
}

View File

@@ -0,0 +1,185 @@
package storage
import (
"context"
"errors"
"fmt"
"io"
"io/fs"
"path/filepath"
"strings"
"github.com/aws/aws-sdk-go-v2/aws"
awscfg "github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/credentials"
"github.com/aws/aws-sdk-go-v2/service/s3"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
)
type S3Config struct {
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
ForcePathStyle bool
Root string
}
type s3Storage struct {
client *s3.Client
bucket string
prefix string
}
func NewS3Storage(ctx context.Context, cfg S3Config) (FileStorage, error) {
creds := credentials.NewStaticCredentialsProvider(cfg.AccessKeyID, cfg.SecretAccessKey, "")
awsCfg, err := awscfg.LoadDefaultConfig(ctx, awscfg.WithRegion(cfg.Region), awscfg.WithCredentialsProvider(creds))
if err != nil {
return nil, fmt.Errorf("failed to load AWS configuration: %w", err)
}
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
if cfg.Endpoint != "" {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
o.UsePathStyle = cfg.ForcePathStyle
})
return &s3Storage{
client: client,
bucket: cfg.Bucket,
prefix: strings.Trim(cfg.Root, "/"),
}, nil
}
func (s *s3Storage) Type() string {
return TypeS3
}
func (s *s3Storage) Save(ctx context.Context, path string, data io.Reader) error {
_, err := s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.buildObjectKey(path)),
Body: data,
})
return err
}
func (s *s3Storage) Open(ctx context.Context, path string) (io.ReadCloser, int64, error) {
resp, err := s.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.buildObjectKey(path)),
})
if err != nil {
if isS3NotFound(err) {
return nil, 0, fs.ErrNotExist
}
return nil, 0, err
}
return resp.Body, aws.ToInt64(resp.ContentLength), nil
}
func (s *s3Storage) Delete(ctx context.Context, path string) error {
_, err := s.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(s.bucket),
Key: aws.String(s.buildObjectKey(path)),
})
return err
}
func (s *s3Storage) DeleteAll(ctx context.Context, path string) error {
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(s.buildObjectKey(path)),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return err
}
if len(page.Contents) == 0 {
continue
}
objects := make([]s3types.ObjectIdentifier, 0, len(page.Contents))
for _, obj := range page.Contents {
objects = append(objects, s3types.ObjectIdentifier{Key: obj.Key})
}
_, err = s.client.DeleteObjects(ctx, &s3.DeleteObjectsInput{
Bucket: aws.String(s.bucket),
Delete: &s3types.Delete{Objects: objects, Quiet: aws.Bool(true)},
})
if err != nil {
return err
}
}
return nil
}
func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error) {
paginator := s3.NewListObjectsV2Paginator(s.client, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucket),
Prefix: aws.String(s.buildObjectKey(path)),
})
var objects []ObjectInfo
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
if obj.Key == nil {
continue
}
objects = append(objects, ObjectInfo{
Path: aws.ToString(obj.Key),
Size: aws.ToInt64(obj.Size),
ModTime: aws.ToTime(obj.LastModified),
})
}
}
return objects, nil
}
func (s *s3Storage) Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error {
objects, err := s.List(ctx, root)
if err != nil {
return err
}
for _, obj := range objects {
if err := fn(obj); err != nil {
return err
}
}
return nil
}
func (s *s3Storage) buildObjectKey(p string) string {
p = filepath.Clean(p)
p = filepath.ToSlash(p)
p = strings.Trim(p, "/")
if p == "" || p == "." {
return s.prefix
}
if s.prefix == "" {
return p
}
return s.prefix + "/" + p
}
func isS3NotFound(err error) bool {
var apiErr smithy.APIError
if errors.As(err, &apiErr) {
if apiErr.ErrorCode() == "NotFound" || apiErr.ErrorCode() == "NoSuchKey" {
return true
}
}
var missingKey *s3types.NoSuchKey
return errors.As(err, &missingKey)
}

View File

@@ -0,0 +1,44 @@
package storage
import (
"errors"
"testing"
s3types "github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/aws/smithy-go"
"github.com/stretchr/testify/assert"
)
func TestS3Helpers(t *testing.T) {
t.Run("buildObjectKey trims and joins prefix", func(t *testing.T) {
tests := []struct {
name string
prefix string
path string
expected string
}{
{name: "no prefix no path", prefix: "", path: "", expected: ""},
{name: "prefix no path", prefix: "root", path: "", expected: "root"},
{name: "prefix with nested path", prefix: "root", path: "foo/bar/baz", expected: "root/foo/bar/baz"},
{name: "trimmed path and prefix", prefix: "root", path: "/foo//bar/", expected: "root/foo/bar"},
{name: "no prefix path only", prefix: "", path: "./images/logo.png", expected: "images/logo.png"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &s3Storage{
bucket: "bucket",
prefix: tc.prefix,
}
assert.Equal(t, tc.expected, s.buildObjectKey(tc.path))
})
}
})
t.Run("isS3NotFound detects expected errors", func(t *testing.T) {
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NoSuchKey"}))
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NotFound"}))
assert.True(t, isS3NotFound(&s3types.NoSuchKey{}))
assert.False(t, isS3NotFound(errors.New("boom")))
})
}

View File

@@ -0,0 +1,33 @@
package storage
import (
"context"
"io"
"os"
"time"
)
var (
TypeFileSystem = "fs"
TypeS3 = "s3"
)
type ObjectInfo struct {
Path string
Size int64
ModTime time.Time
}
type FileStorage interface {
Save(ctx context.Context, relativePath string, data io.Reader) error
Open(ctx context.Context, relativePath string) (io.ReadCloser, int64, error)
Delete(ctx context.Context, relativePath string) error
DeleteAll(ctx context.Context, prefix string) error
List(ctx context.Context, prefix string) ([]ObjectInfo, error)
Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error
Type() string
}
func IsNotExist(err error) bool {
return os.IsNotExist(err)
}

View File

@@ -2,19 +2,15 @@ package utils
import ( import (
"crypto/rand" "crypto/rand"
"crypto/sha256"
"encoding/hex" "encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"syscall" "syscall"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/resources"
) )
func GetFileExtension(filename string) string { func GetFileExtension(filename string) string {
@@ -57,108 +53,32 @@ func GetImageMimeType(ext string) string {
} }
} }
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error { func GetImageExtensionFromMimeType(mimeType string) string {
srcFile, err := resources.FS.Open(srcFilePath) // Normalize and strip parameters like `; charset=utf-8`
if err != nil { mt := strings.TrimSpace(strings.ToLower(mimeType))
return fmt.Errorf("failed to open embedded file: %w", err) if v, _, err := mime.ParseMediaType(mt); err == nil {
mt = v
} }
defer srcFile.Close() switch mt {
case "image/jpeg", "image/jpg":
err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm) return "jpg"
if err != nil { case "image/png":
return fmt.Errorf("failed to create destination directory: %w", err) return "png"
case "image/svg+xml":
return "svg"
case "image/x-icon", "image/vnd.microsoft.icon":
return "ico"
case "image/gif":
return "gif"
case "image/webp":
return "webp"
case "image/avif":
return "avif"
case "image/heic", "image/heif":
return "heic"
default:
return ""
} }
destFile, err := os.Create(destFilePath)
if err != nil {
return fmt.Errorf("failed to open destination file: %w", err)
}
defer destFile.Close()
_, err = io.Copy(destFile, srcFile)
if err != nil {
return fmt.Errorf("failed to write to destination file: %w", err)
}
return nil
}
func EmbeddedFileSha256(filePath string) ([]byte, error) {
f, err := resources.FS.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file: %w", err)
}
defer f.Close()
h := sha256.New()
_, err = io.Copy(h, f)
if err != nil {
return nil, fmt.Errorf("failed to read embedded file: %w", err)
}
return h.Sum(nil), nil
}
func SaveFile(file *multipart.FileHeader, dst string) error {
src, err := file.Open()
if err != nil {
return err
}
defer src.Close()
if err = os.MkdirAll(filepath.Dir(dst), 0o750); err != nil {
return err
}
return SaveFileStream(src, dst)
}
// SaveFileStream saves a stream to a file.
func SaveFileStream(r io.Reader, dstFileName string) error {
// Our strategy is to save to a separate file and then rename it to override the original file
tmpFileName := dstFileName + "." + uuid.NewString() + "-tmp"
// Write to the temporary file
tmpFile, err := os.Create(tmpFileName)
if err != nil {
return fmt.Errorf("failed to open file '%s' for writing: %w", tmpFileName, err)
}
n, err := io.Copy(tmpFile, r)
if err != nil {
// Delete the temporary file; we ignore errors here
_ = tmpFile.Close()
_ = os.Remove(tmpFileName)
return fmt.Errorf("failed to write to file '%s': %w", tmpFileName, err)
}
err = tmpFile.Close()
if err != nil {
// Delete the temporary file; we ignore errors here
_ = os.Remove(tmpFileName)
return fmt.Errorf("failed to close stream to file '%s': %w", tmpFileName, err)
}
if n == 0 {
// Delete the temporary file; we ignore errors here
_ = os.Remove(tmpFileName)
return errors.New("no data written")
}
// Rename to the final file, which overrides existing files
// This is an atomic operation
err = os.Rename(tmpFileName, dstFileName)
if err != nil {
// Delete the temporary file; we ignore errors here
_ = os.Remove(tmpFileName)
return fmt.Errorf("failed to rename file '%s': %w", dstFileName, err)
}
return nil
} }
// FileExists returns true if a file exists on disk and is a regular file // FileExists returns true if a file exists on disk and is a regular file

View File

@@ -19,7 +19,7 @@ import (
const profilePictureSize = 300 const profilePictureSize = 300
// CreateProfilePicture resizes the profile picture to a square // CreateProfilePicture resizes the profile picture to a square
func CreateProfilePicture(file io.Reader) (io.Reader, error) { func CreateProfilePicture(file io.Reader) (io.ReadSeeker, error) {
img, _, err := imageorient.Decode(file) img, _, err := imageorient.Decode(file)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to decode image: %w", err) return nil, fmt.Errorf("failed to decode image: %w", err)
@@ -27,17 +27,13 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos) img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos)
pr, pw := io.Pipe() var buf bytes.Buffer
go func() { err = imaging.Encode(&buf, img, imaging.PNG)
innerErr := imaging.Encode(pw, img, imaging.PNG) if err != nil {
if innerErr != nil { return nil, fmt.Errorf("failed to encode image: %w", err)
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", innerErr))
return
} }
pw.Close()
}()
return pr, nil return bytes.NewReader(buf.Bytes()), nil
} }
// CreateDefaultProfilePicture creates a profile picture with the initials // CreateDefaultProfilePicture creates a profile picture with the initials

View File

@@ -0,0 +1,87 @@
package utils
import (
"net"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
var localIPv6Ranges []*net.IPNet
var localhostIPNets = []*net.IPNet{
{IP: net.IPv4(127, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 127.0.0.0/8
{IP: net.IPv6loopback, Mask: net.CIDRMask(128, 128)}, // ::1/128
}
var privateLanIPNets = []*net.IPNet{
{IP: net.IPv4(10, 0, 0, 0), Mask: net.CIDRMask(8, 32)}, // 10.0.0.0/8
{IP: net.IPv4(172, 16, 0, 0), Mask: net.CIDRMask(12, 32)}, // 172.16.0.0/12
{IP: net.IPv4(192, 168, 0, 0), Mask: net.CIDRMask(16, 32)}, // 192.168.0.0/16
}
var tailscaleIPNets = []*net.IPNet{
{IP: net.IPv4(100, 64, 0, 0), Mask: net.CIDRMask(10, 32)}, // 100.64.0.0/10
}
func IsLocalIPv6(ip net.IP) bool {
if ip.To4() != nil {
return false
}
return listContainsIP(localIPv6Ranges, ip)
}
func IsLocalhostIP(ip net.IP) bool {
return listContainsIP(localhostIPNets, ip)
}
func IsPrivateLanIP(ip net.IP) bool {
if ip.To4() == nil {
return false
}
return listContainsIP(privateLanIPNets, ip)
}
func IsTailscaleIP(ip net.IP) bool {
if ip.To4() == nil {
return false
}
return listContainsIP(tailscaleIPNets, ip)
}
func IsPrivateIP(ip net.IP) bool {
return IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip)
}
func listContainsIP(ipNets []*net.IPNet, ip net.IP) bool {
for _, ipNet := range ipNets {
if ipNet.Contains(ip) {
return true
}
}
return false
}
func loadLocalIPv6Ranges() {
localIPv6Ranges = nil
ranges := strings.Split(common.EnvConfig.LocalIPv6Ranges, ",")
for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" {
continue
}
_, ipNet, err := net.ParseCIDR(rangeStr)
if err == nil {
localIPv6Ranges = append(localIPv6Ranges, ipNet)
}
}
}
func init() {
loadLocalIPv6Ranges()
}

View File

@@ -0,0 +1,159 @@
package utils
import (
"net"
"testing"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
func TestIsLocalhostIP(t *testing.T) {
tests := []struct {
ip string
expected bool
}{
{"127.0.0.1", true},
{"127.255.255.255", true},
{"::1", true},
{"192.168.1.1", false},
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := IsLocalhostIP(ip); got != tt.expected {
t.Errorf("IsLocalhostIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestIsPrivateLanIP(t *testing.T) {
tests := []struct {
ip string
expected bool
}{
{"10.0.0.1", true},
{"172.16.5.4", true},
{"192.168.100.200", true},
{"8.8.8.8", false},
{"::1", false}, // IPv6 should return false
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := IsPrivateLanIP(ip); got != tt.expected {
t.Errorf("IsPrivateLanIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestIsTailscaleIP(t *testing.T) {
tests := []struct {
ip string
expected bool
}{
{"100.64.0.1", true},
{"100.127.255.254", true},
{"8.8.8.8", false},
{"::1", false}, // IPv6 should return false
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := IsTailscaleIP(ip); got != tt.expected {
t.Errorf("IsTailscaleIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestIsLocalIPv6(t *testing.T) {
// Save and restore env config
origRanges := common.EnvConfig.LocalIPv6Ranges
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
common.EnvConfig.LocalIPv6Ranges = "fd00::/8,fc00::/7"
localIPv6Ranges = nil // reset
loadLocalIPv6Ranges()
tests := []struct {
ip string
expected bool
}{
{"fd00::1", true},
{"fc00::abcd", true},
{"::1", false}, // loopback handled separately
{"192.168.1.1", false}, // IPv4 should return false
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := IsLocalIPv6(ip); got != tt.expected {
t.Errorf("IsLocalIPv6(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestIsPrivateIP(t *testing.T) {
// Save and restore env config
origRanges := common.EnvConfig.LocalIPv6Ranges
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
common.EnvConfig.LocalIPv6Ranges = "fd00::/8"
localIPv6Ranges = nil // reset
loadLocalIPv6Ranges()
tests := []struct {
ip string
expected bool
}{
{"127.0.0.1", true}, // localhost
{"192.168.1.1", true}, // private LAN
{"100.64.0.1", true}, // Tailscale
{"fd00::1", true}, // local IPv6
{"8.8.8.8", false}, // public IPv4
{"2001:4860:4860::8888", false}, // public IPv6
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := IsPrivateIP(ip); got != tt.expected {
t.Errorf("IsPrivateIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestListContainsIP(t *testing.T) {
_, ipNet1, _ := net.ParseCIDR("10.0.0.0/8")
_, ipNet2, _ := net.ParseCIDR("192.168.0.0/16")
list := []*net.IPNet{ipNet1, ipNet2}
tests := []struct {
ip string
expected bool
}{
{"10.1.1.1", true},
{"192.168.5.5", true},
{"172.16.0.1", false},
}
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
if got := listContainsIP(list, ip); got != tt.expected {
t.Errorf("listContainsIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestInit_LocalIPv6Ranges(t *testing.T) {
// Save and restore env config
origRanges := common.EnvConfig.LocalIPv6Ranges
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
common.EnvConfig.LocalIPv6Ranges = "fd00::/8, invalidCIDR ,fc00::/7"
localIPv6Ranges = nil
loadLocalIPv6Ranges()
if len(localIPv6Ranges) != 2 {
t.Errorf("expected 2 valid IPv6 ranges, got %d", len(localIPv6Ranges))
}
}

View File

@@ -3,6 +3,7 @@ package utils
import ( import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt"
"time" "time"
) )
@@ -40,3 +41,14 @@ func (d *JSONDuration) UnmarshalJSON(b []byte) error {
return errors.New("invalid duration") return errors.New("invalid duration")
} }
} }
func UnmarshalJSONFromDatabase(data interface{}, value any) error {
switch v := value.(type) {
case []byte:
return json.Unmarshal(v, data)
case string:
return json.Unmarshal([]byte(v), data)
default:
return fmt.Errorf("unsupported type: %T", value)
}
}

View File

@@ -0,0 +1,205 @@
package utils
import (
"reflect"
"strings"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PaginationResponse struct {
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
ItemsPerPage int `json:"itemsPerPage"`
}
type ListRequestOptions struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
Filters map[string][]any
}
type FieldMeta struct {
ColumnName string
IsSortable bool
IsFilterable bool
}
func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOptions) {
if err := ctx.ShouldBindQuery(&listRequestOptions); err != nil {
return listRequestOptions
}
listRequestOptions.Filters = parseNestedFilters(ctx)
return listRequestOptions
}
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result interface{}) (PaginationResponse, error) {
meta := extractModelMetadata(result)
query = applyFilters(params.Filters, query, meta)
query = applySorting(params.Sort.Column, params.Sort.Direction, query, meta)
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
} else if pageSize > 100 {
pageSize = 100
}
var totalItems int64
if err := query.Count(&totalItems).Error; err != nil {
return PaginationResponse{}, err
}
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
if int64(page) > totalPages {
page = int(totalPages)
}
offset := (page - 1) * pageSize
if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
return PaginationResponse{}, err
}
return PaginationResponse{
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,
}, 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"
}
// parseNestedFilters handles ?filters[field][0]=val1&filters[field][1]=val2
func parseNestedFilters(ctx *gin.Context) map[string][]any {
result := make(map[string][]any)
query := ctx.Request.URL.Query()
for key, values := range query {
if !strings.HasPrefix(key, "filters[") {
continue
}
// Keys can be "filters[field]" or "filters[field][0]"
raw := strings.TrimPrefix(key, "filters[")
// Take everything up to the first closing bracket
if idx := strings.IndexByte(raw, ']'); idx != -1 {
field := raw[:idx]
for _, v := range values {
result[field] = append(result[field], ConvertStringToType(v))
}
}
}
return result
}
// applyFilters applies filtering to the GORM query based on the provided filters
func applyFilters(filters map[string][]any, query *gorm.DB, meta map[string]FieldMeta) *gorm.DB {
for key, values := range filters {
if key == "" || len(values) == 0 {
continue
}
fieldName := CapitalizeFirstLetter(key)
fieldMeta, ok := meta[fieldName]
if !ok || !fieldMeta.IsFilterable {
continue
}
query = query.Where(fieldMeta.ColumnName+" IN ?", values)
}
return query
}
// applySorting applies sorting to the GORM query based on the provided column and direction
func applySorting(sortColumn string, sortDirection string, query *gorm.DB, meta map[string]FieldMeta) *gorm.DB {
fieldName := CapitalizeFirstLetter(sortColumn)
fieldMeta, ok := meta[fieldName]
if !ok || !fieldMeta.IsSortable {
return query
}
sortDirection = NormalizeSortDirection(sortDirection)
query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{
{Column: clause.Column{Name: fieldMeta.ColumnName}, Desc: sortDirection == "desc"},
},
})
return query
}
// extractModelMetadata extracts FieldMeta from the model struct using reflection
func extractModelMetadata(model interface{}) map[string]FieldMeta {
meta := make(map[string]FieldMeta)
// Unwrap pointers and slices to get the element struct type
t := reflect.TypeOf(model)
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem()
if t == nil {
return meta
}
}
// recursive parser that merges fields from embedded structs
var parseStruct func(reflect.Type)
parseStruct = func(st reflect.Type) {
for i := 0; i < st.NumField(); i++ {
field := st.Field(i)
ft := field.Type
// If the field is an embedded/anonymous struct, recurse into it
if field.Anonymous && ft.Kind() == reflect.Struct {
parseStruct(ft)
continue
}
// Normal field: record metadata
name := field.Name
meta[name] = FieldMeta{
ColumnName: CamelCaseToSnakeCase(name),
IsSortable: field.Tag.Get("sortable") == "true",
IsFilterable: field.Tag.Get("filterable") == "true",
}
}
}
parseStruct(t)
return meta
}

View File

@@ -1,99 +0,0 @@
package utils
import (
"reflect"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PaginationResponse struct {
TotalPages int64 `json:"totalPages"`
TotalItems int64 `json:"totalItems"`
CurrentPage int `json:"currentPage"`
ItemsPerPage int `json:"itemsPerPage"`
}
type SortedPaginationRequest struct {
Pagination struct {
Page int `form:"pagination[page]"`
Limit int `form:"pagination[limit]"`
} `form:"pagination"`
Sort struct {
Column string `form:"sort[column]"`
Direction string `form:"sort[direction]"`
} `form:"sort"`
}
func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gorm.DB, result interface{}) (PaginationResponse, error) {
pagination := sortedPaginationRequest.Pagination
sort := sortedPaginationRequest.Sort
capitalizedSortColumn := CapitalizeFirstLetter(sort.Column)
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
sort.Direction = NormalizeSortDirection(sort.Direction)
if sortFieldFound && isSortable {
columnName := CamelCaseToSnakeCase(sort.Column)
query = query.Clauses(clause.OrderBy{
Columns: []clause.OrderByColumn{
{Column: clause.Column{Name: columnName}, Desc: sort.Direction == "desc"},
},
})
}
return Paginate(pagination.Page, pagination.Limit, query, result)
}
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
} else if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
var totalItems int64
if err := query.Count(&totalItems).Error; err != nil {
return PaginationResponse{}, err
}
if err := query.Offset(offset).Limit(pageSize).Find(result).Error; err != nil {
return PaginationResponse{}, err
}
totalPages := (totalItems + int64(pageSize) - 1) / int64(pageSize)
if totalItems == 0 {
totalPages = 1
}
return PaginationResponse{
TotalPages: totalPages,
TotalItems: totalItems,
CurrentPage: page,
ItemsPerPage: pageSize,
}, 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

@@ -1,13 +1,16 @@
package utils package utils
// Ptr returns a pointer to the given value.
func Ptr[T any](v T) *T { func Ptr[T any](v T) *T {
return &v return &v
} }
func PtrValueOrZero[T any](ptr *T) T { // PtrOrNil returns a pointer to v if v is not the zero value of its type,
if ptr == nil { // otherwise it returns nil.
func PtrOrNil[T comparable](v T) *T {
var zero T var zero T
return zero if v == zero {
return nil
} }
return *ptr return &v
} }

View File

@@ -81,26 +81,21 @@ func CapitalizeFirstLetter(str string) string {
return result.String() return result.String()
} }
func CamelCaseToSnakeCase(str string) string { var (
result := strings.Builder{} reAcronymBoundary = regexp.MustCompile(`([A-Z]+)([A-Z][a-z])`) // ABCd -> AB_Cd
result.Grow(int(float32(len(str)) * 1.1)) reLowerToUpper = regexp.MustCompile(`([a-z0-9])([A-Z])`) // aB -> a_B
for i, r := range str { )
if unicode.IsUpper(r) && i > 0 {
result.WriteByte('_')
}
result.WriteRune(unicode.ToLower(r))
}
return result.String()
}
var camelCaseToScreamingSnakeCaseRe = regexp.MustCompile(`([a-z0-9])([A-Z])`) func CamelCaseToSnakeCase(s string) string {
s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
return strings.ToLower(s)
}
func CamelCaseToScreamingSnakeCase(s string) string { func CamelCaseToScreamingSnakeCase(s string) string {
// Insert underscores before uppercase letters (except the first one) s = reAcronymBoundary.ReplaceAllString(s, "${1}_${2}")
snake := camelCaseToScreamingSnakeCaseRe.ReplaceAllString(s, `${1}_${2}`) s = reLowerToUpper.ReplaceAllString(s, "${1}_${2}")
return strings.ToUpper(s)
// Convert to uppercase
return strings.ToUpper(snake)
} }
// GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode // GetFirstCharacter returns the first non-whitespace character of the string, correctly handling Unicode

View File

@@ -86,9 +86,9 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
{"simple camelCase", "camelCase", "camel_case"}, {"simple camelCase", "camelCase", "camel_case"},
{"PascalCase", "PascalCase", "pascal_case"}, {"PascalCase", "PascalCase", "pascal_case"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"}, {"multipleWordsInCamelCase", "multipleWordsInCamelCase", "multiple_words_in_camel_case"},
{"consecutive uppercase", "HTTPRequest", "h_t_t_p_request"}, {"consecutive uppercase", "HTTPRequest", "http_request"},
{"single lowercase word", "word", "word"}, {"single lowercase word", "word", "word"},
{"single uppercase word", "WORD", "w_o_r_d"}, {"single uppercase word", "WORD", "word"},
{"with numbers", "camel123Case", "camel123_case"}, {"with numbers", "camel123Case", "camel123_case"},
{"with numbers in middle", "model2Name", "model2_name"}, {"with numbers in middle", "model2Name", "model2_name"},
{"mixed case", "iPhone6sPlus", "i_phone6s_plus"}, {"mixed case", "iPhone6sPlus", "i_phone6s_plus"},
@@ -104,6 +104,34 @@ func TestCamelCaseToSnakeCase(t *testing.T) {
} }
} }
func TestCamelCaseToScreamingSnakeCase(t *testing.T) {
tests := []struct {
name string
input string
expected string
}{
{"empty string", "", ""},
{"simple camelCase", "camelCase", "CAMEL_CASE"},
{"PascalCase", "PascalCase", "PASCAL_CASE"},
{"multipleWordsInCamelCase", "multipleWordsInCamelCase", "MULTIPLE_WORDS_IN_CAMEL_CASE"},
{"consecutive uppercase", "HTTPRequest", "HTTP_REQUEST"},
{"single lowercase word", "word", "WORD"},
{"single uppercase word", "WORD", "WORD"},
{"with numbers", "camel123Case", "CAMEL123_CASE"},
{"with numbers in middle", "model2Name", "MODEL2_NAME"},
{"mixed case", "iPhone6sPlus", "I_PHONE6S_PLUS"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := CamelCaseToScreamingSnakeCase(tt.input)
if result != tt.expected {
t.Errorf("CamelCaseToScreamingSnakeCase(%q) = %q, want %q", tt.input, result, tt.expected)
}
})
}
}
func TestGetFirstCharacter(t *testing.T) { func TestGetFirstCharacter(t *testing.T) {
tests := []struct { tests := []struct {
name string name string

View File

@@ -36,7 +36,7 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
// Connect to a new in-memory SQL database // Connect to a new in-memory SQL database
db, err := gorm.Open( db, err := gorm.Open(
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"), sqlite.Open("file:"+dbName+"?mode=memory"),
&gorm.Config{ &gorm.Config{
TranslateError: true, TranslateError: true,
Logger: logger.New( Logger: logger.New(
@@ -52,9 +52,14 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
}) })
require.NoError(t, err, "Failed to connect to test database") require.NoError(t, err, "Failed to connect to test database")
// Perform migrations with the embedded migrations
sqlDB, err := db.DB() sqlDB, err := db.DB()
require.NoError(t, err, "Failed to get sql.DB") require.NoError(t, err, "Failed to get sql.DB")
// For in-memory SQLite databases, we must limit to 1 open connection at the same time, or they won't see the whole data
// The other workaround, of using shared caches, doesn't work well with multiple write transactions trying to happen at once
sqlDB.SetMaxOpenConns(1)
// Perform migrations with the embedded migrations
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{ driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{
NoTxWrap: true, NoTxWrap: true,
}) })

View File

@@ -0,0 +1,35 @@
package utils
import (
"strconv"
"strings"
)
// ConvertStringToType attempts to convert a string to bool, int, or float.
func ConvertStringToType(value string) any {
v := strings.TrimSpace(value)
if v == "" {
return v
}
// Try bool
if v == "true" {
return true
}
if v == "false" {
return false
}
// Try int
if i, err := strconv.Atoi(v); err == nil {
return i
}
// Try float
if f, err := strconv.ParseFloat(v, 64); err == nil {
return f
}
// Default: string
return v
}

View File

@@ -0,0 +1,37 @@
package utils
import (
"testing"
)
func TestConvertStringToType(t *testing.T) {
tests := []struct {
input string
expected any
}{
{"true", true},
{"false", false},
{" true ", true},
{" false ", false},
{"42", 42},
{" 42 ", 42},
{"3.14", 3.14},
{" 3.14 ", 3.14},
{"hello", "hello"},
{" hello ", "hello"},
{"", ""},
{" ", ""},
}
for _, tt := range tests {
result := ConvertStringToType(tt.input)
if result != tt.expected {
if f, ok := tt.expected.(float64); ok {
if rf, ok := result.(float64); ok && rf == f {
continue
}
}
t.Errorf("ConvertStringToType(%q) = %#v (type %T), want %#v (type %T)", tt.input, result, result, tt.expected, tt.expected)
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -1,3 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"> {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column">
<p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -1,5 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"> {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if .Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column">
<p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p>
<p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.City}}<!-- -->, <!-- -->{{.Data.Country}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">
{{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -12,7 +12,8 @@ DETAILS
Approximate Location Approximate Location
{{.Data.City}}, {{.Data.Country}} {{if and .Data.City .Data.Country}}{{.Data.City}}, {{.Data.Country}}{{else if
.Data.Country}}{{.Data.Country}}{{else}}Unknown{{end}}
IP Address IP Address

View File

@@ -1,4 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"> {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span><span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span>
<span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -1,3 +1 @@
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"> {{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><!--$--><!--html--><!--head--><!--body--><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Your email setup is working correctly!</p></div></td></tr></tbody></table><!--/$--></body></html>{{end}}
<img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
Your email setup is working correctly!</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}

View File

@@ -0,0 +1 @@
-- No-op because email was optional before the migration

View File

@@ -0,0 +1 @@
ALTER TABLE users ALTER COLUMN email DROP NOT NULL;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients DROP COLUMN dark_image_type;

View File

@@ -0,0 +1 @@
ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE webauthn_sessions DROP COLUMN credential_params;

View File

@@ -0,0 +1 @@
ALTER TABLE webauthn_sessions ADD COLUMN credential_params JSONB NOT NULL DEFAULT '[]';

View File

@@ -0,0 +1,4 @@
DROP INDEX IF EXISTS idx_api_keys_key;
DROP INDEX IF EXISTS idx_oidc_refresh_tokens_token;
DROP INDEX IF EXISTS idx_signup_tokens_token;
DROP INDEX IF EXISTS idx_reauthentication_tokens_token;

View File

@@ -0,0 +1 @@
-- No-op because email was optional before the migration

View File

@@ -0,0 +1,40 @@
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE users_new
(
id TEXT NOT NULL PRIMARY KEY,
created_at DATETIME,
username TEXT NOT NULL COLLATE NOCASE UNIQUE,
email TEXT UNIQUE,
first_name TEXT,
last_name TEXT NOT NULL,
display_name TEXT NOT NULL,
is_admin NUMERIC NOT NULL DEFAULT FALSE,
ldap_id TEXT,
locale TEXT,
disabled NUMERIC NOT NULL DEFAULT FALSE
);
INSERT INTO users_new (id, created_at, username, email, first_name, last_name, display_name, is_admin, ldap_id, locale,
disabled)
SELECT id,
created_at,
username,
email,
first_name,
last_name,
display_name,
is_admin,
ldap_id,
locale,
disabled
FROM users;
DROP TABLE users;
ALTER TABLE users_new
RENAME TO users;
COMMIT;
PRAGMA foreign_keys = ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients DROP COLUMN dark_image_type;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients ADD COLUMN dark_image_type TEXT;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE webauthn_sessions DROP COLUMN credential_params;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -0,0 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE webauthn_sessions ADD COLUMN credential_params TEXT NOT NULL DEFAULT '[]';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -27,16 +27,12 @@ body = """
{% for group, commits in commits | group_by(attribute="group") %} {% for group, commits in commits | group_by(attribute="group") %}
### {{ group | title }} ### {{ group | title }}
{% for commit in commits %} {% for commit in commits %}
* {{ commit.message }} \ - {{ commit.message | trim }} \
{%- if commit.remote.pr_number -%} {%- if commit.remote.pr_number %} ([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) by @{{ commit.remote.username | default(value=commit.author.name) }}){%- else %} ([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}) by @{{ commit.remote.username | default(value=commit.author.name) }}){%- endif -%}
([#{{ commit.remote.pr_number }}]({{ self::remote_url() }}/pull/{{ commit.remote.pr_number }}) by @{{ commit.remote.username | default(value=commit.author.name) }})
{%- else -%}
([{{ commit.id | truncate(length=7, end="") }}]({{ self::remote_url() }}/commit/{{ commit.id }}) by @{{ commit.remote.username | default(value=commit.author.name) }})
{%- endif -%}
{% endfor %} {% endfor %}
{% endfor %} {% endfor %}
{% if version %} {% if version -%}
{% if previous.version %} {% if previous.version -%}
**Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }} **Full Changelog**: {{ self::remote_url() }}/compare/{{ previous.version }}...{{ version }}
{% endif %} {% endif %}
{% else -%} {% else -%}

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