mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 14:42:59 +03:00
Compare commits
142 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8973e93cb6 | ||
|
|
8c9cac2655 | ||
|
|
ed8547ccc1 | ||
|
|
e7e53a8b8c | ||
|
|
02249491f8 | ||
|
|
cf0892922b | ||
|
|
99f31a7c26 | ||
|
|
68373604dd | ||
|
|
2d6d5df0e7 | ||
|
|
a897b31166 | ||
|
|
fb92906c3a | ||
|
|
c018f29ad7 | ||
|
|
5367463239 | ||
|
|
6c9147483c | ||
|
|
d123d7f335 | ||
|
|
da8ca08c36 | ||
|
|
307caaa3ef | ||
|
|
6c696b46c8 | ||
|
|
42155238b7 | ||
|
|
92edc26a30 | ||
|
|
e36499c483 | ||
|
|
6215e1ac01 | ||
|
|
74b39e16f9 | ||
|
|
a1d8538c64 | ||
|
|
1d7cbc2a4e | ||
|
|
954fb4f0c8 | ||
|
|
901333f7e4 | ||
|
|
0b381467ca | ||
|
|
6188dc6fb7 | ||
|
|
802754c24c | ||
|
|
6c843228eb | ||
|
|
a3979f63e0 | ||
|
|
52c560c30d | ||
|
|
e88be7e61a | ||
|
|
a4e965434f | ||
|
|
096d214a88 | ||
|
|
afb7fc32e7 | ||
|
|
641bbc9351 | ||
|
|
136c6082f6 | ||
|
|
b9a20d2923 | ||
|
|
74eb2ac0b9 | ||
|
|
51222f5607 | ||
|
|
d6d1a4ced2 | ||
|
|
4b086cebcd | ||
|
|
1f3550c9bd | ||
|
|
912008b048 | ||
|
|
5ad8b03831 | ||
|
|
c1e515a05f | ||
|
|
654593b4b6 | ||
|
|
8999173aa0 | ||
|
|
10b087640f | ||
|
|
d0392d25ed | ||
|
|
2ffc6ba42a | ||
|
|
c114a2edaa | ||
|
|
63db4d5120 | ||
|
|
d8c73ed472 | ||
|
|
5971bfbfa6 | ||
|
|
29eacd6424 | ||
|
|
21ca87be38 | ||
|
|
1283314f77 | ||
|
|
9c54e2e6b0 | ||
|
|
a5efb95065 | ||
|
|
625f235740 | ||
|
|
2c122d413d | ||
|
|
fc0c99a232 | ||
|
|
24e274200f | ||
|
|
0aab3f3c7a | ||
|
|
182d809028 | ||
|
|
c51265dafb | ||
|
|
0cb039d35d | ||
|
|
7ab0fd3028 | ||
|
|
49f0fa423c | ||
|
|
61e63e411d | ||
|
|
9339e88a5a | ||
|
|
fe003b927c | ||
|
|
f5b5b1bd85 | ||
|
|
d28bfac81f | ||
|
|
b04e3e8ecf | ||
|
|
d77d8eb068 | ||
|
|
7cd88aca25 | ||
|
|
b5e6371eaa | ||
|
|
544b98c1d0 | ||
|
|
3188e92257 | ||
|
|
3fa2f9a162 | ||
|
|
7b1f6b8857 | ||
|
|
17d8893bdb | ||
|
|
0e44f245af | ||
|
|
824e8f1a0f | ||
|
|
6e4d2a4a33 | ||
|
|
6c65bd34cd | ||
|
|
7bfe4834d0 | ||
|
|
484c2f6ef2 | ||
|
|
87956ea725 | ||
|
|
32dd403038 | ||
|
|
4d59e72866 | ||
|
|
9ac5d51187 | ||
|
|
5a031f5d1b | ||
|
|
535bc9f46b | ||
|
|
f0c144c51c | ||
|
|
61e4ea45fb | ||
|
|
06e1656923 | ||
|
|
0a3b1c6530 | ||
|
|
d479817b6a | ||
|
|
01bf31d23d | ||
|
|
42a861d206 | ||
|
|
78266e3e4c | ||
|
|
c8478d75be | ||
|
|
28d93b00a3 | ||
|
|
12a7a6a5c5 | ||
|
|
a6d5071724 | ||
|
|
cebe2242b9 | ||
|
|
56ee7d946f | ||
|
|
f3c6521f2b | ||
|
|
ffed465f09 | ||
|
|
c359b5be06 | ||
|
|
e9a023bb71 | ||
|
|
60f0b28076 | ||
|
|
d541c9ab4a | ||
|
|
024ed53022 | ||
|
|
2c78bd1b46 | ||
|
|
5602d79611 | ||
|
|
51b73c9c31 | ||
|
|
10f0580a43 | ||
|
|
a1488565ea | ||
|
|
35d5f887ce | ||
|
|
4c76de45ed | ||
|
|
68fc9c0659 | ||
|
|
2952b15755 | ||
|
|
ef1d599662 | ||
|
|
4e49d3932a | ||
|
|
86d3c08494 | ||
|
|
7b4ccd1f30 | ||
|
|
f145903eb0 | ||
|
|
d3bc1797b6 | ||
|
|
db94f81937 | ||
|
|
b03e91b653 | ||
|
|
505bdcb8ba | ||
|
|
f103a54790 | ||
|
|
e1de593dcd | ||
|
|
45f42772b1 | ||
|
|
98152640b1 | ||
|
|
04e235e805 |
@@ -1,4 +1,4 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
|
||||
24
.github/ISSUE_TEMPLATE/bug.yml
vendored
24
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: "🐛 Bug Report"
|
||||
description: "Report something that is not working as expected"
|
||||
title: "🐛 Bug Report: "
|
||||
labels: [bug]
|
||||
type: 'Bug'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -36,13 +36,29 @@ body:
|
||||
value: |
|
||||
### Additional Information
|
||||
- type: textarea
|
||||
id: extra-information
|
||||
id: version
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Version and Environment"
|
||||
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||
label: "Pocket ID Version"
|
||||
description: "Please specify the version of Pocket ID."
|
||||
placeholder: "e.g., v0.24.1"
|
||||
- type: textarea
|
||||
id: database
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Database"
|
||||
description: "Please specify the database in use: SQLite or Postgres (including version)."
|
||||
placeholder: "e.g., SQLite or Postgres 17"
|
||||
- type: textarea
|
||||
id: environment
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "OS and Environment"
|
||||
description: "Please include the OS, whether you're using containers (Docker, Podman, etc) along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||
placeholder: "e.g., Docker on Ubuntu 24.04, served using Traefik"
|
||||
- type: textarea
|
||||
id: log-files
|
||||
validations:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🚀 Feature
|
||||
description: "Submit a proposal for a new feature"
|
||||
title: "🚀 Feature: "
|
||||
labels: [feature]
|
||||
type: 'Feature'
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: "🌐 Language request"
|
||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||
title: "🌐 Language Request: <language name in english>"
|
||||
labels: [language-request]
|
||||
type: 'Language Request'
|
||||
body:
|
||||
- type: input
|
||||
id: language-name-native
|
||||
|
||||
4
.github/workflows/backend-linter.yml
vendored
4
.github/workflows/backend-linter.yml
vendored
@@ -32,9 +32,9 @@ jobs:
|
||||
go-version-file: backend/go.mod
|
||||
|
||||
- name: Run Golangci-lint
|
||||
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
|
||||
uses: golangci/golangci-lint-action@v8.0.0
|
||||
with:
|
||||
version: v2.0.2
|
||||
version: v2.4.0
|
||||
args: --build-tags=exclude_frontend
|
||||
working-directory: backend
|
||||
only-new-issues: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
18
.github/workflows/build-next.yml
vendored
18
.github/workflows/build-next.yml
vendored
@@ -21,17 +21,20 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "backend/go.mod"
|
||||
go-version-file: 'backend/go.mod'
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -54,12 +57,11 @@ jobs:
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
run: pnpm run build
|
||||
|
||||
- name: Build binaries
|
||||
run: sh scripts/development/build-binaries.sh --docker-only
|
||||
@@ -85,12 +87,12 @@ jobs:
|
||||
- name: Container image attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-digest: ${{ steps.build-push-image.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Container image attestation (distroless)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||
push-to-registry: true
|
||||
|
||||
185
.github/workflows/e2e-tests.yml
vendored
185
.github/workflows/e2e-tests.yml
vendored
@@ -3,15 +3,15 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.github/**'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**.md"
|
||||
- ".github/**"
|
||||
- 'docs/**'
|
||||
- '**.md'
|
||||
- '.github/**'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -45,39 +45,52 @@ jobs:
|
||||
path: /tmp/docker-image.tar
|
||||
retention-days: 1
|
||||
|
||||
test-sqlite:
|
||||
test:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: [sqlite, postgres]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Cache PostgreSQL Docker image
|
||||
if: matrix.db == 'postgres'
|
||||
uses: actions/cache@v3
|
||||
id: postgres-cache
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
path: /tmp/postgres-image.tar
|
||||
key: postgres-17-${{ runner.os }}
|
||||
|
||||
- name: Load Docker image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
- name: Pull and save PostgreSQL image
|
||||
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker pull postgres:17
|
||||
docker save postgres:17 > /tmp/postgres-image.tar
|
||||
|
||||
- name: Load PostgreSQL image from cache
|
||||
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/postgres-image.tar
|
||||
- name: Cache LLDAP Docker image
|
||||
uses: actions/cache@v3
|
||||
id: lldap-cache
|
||||
@@ -94,31 +107,45 @@ jobs:
|
||||
- name: Load LLDAP image from cache
|
||||
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/lldap-image.tar
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
|
||||
- name: Load Docker image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
|
||||
- name: Install test dependencies
|
||||
working-directory: ./tests
|
||||
run: npm ci
|
||||
run: pnpm --filter pocket-id-tests install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: ./tests
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Docker Container with Sqlite DB and LDAP
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Run Docker Container (sqlite) with LDAP
|
||||
if: matrix.db == 'sqlite'
|
||||
working-directory: ./tests/setup
|
||||
run: |
|
||||
docker compose up -d
|
||||
docker compose logs -f pocket-id &> /tmp/backend.log &
|
||||
|
||||
- name: Run Docker Container (postgres) with LDAP
|
||||
if: matrix.db == 'postgres'
|
||||
working-directory: ./tests/setup
|
||||
run: |
|
||||
docker compose -f docker-compose-postgres.yml up -d
|
||||
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./tests
|
||||
run: npx playwright test
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: playwright-report-sqlite
|
||||
name: playwright-report-${{ matrix.db }}
|
||||
path: tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
@@ -127,111 +154,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: backend-sqlite
|
||||
path: /tmp/backend.log
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
test-postgres:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Cache PostgreSQL Docker image
|
||||
uses: actions/cache@v3
|
||||
id: postgres-cache
|
||||
with:
|
||||
path: /tmp/postgres-image.tar
|
||||
key: postgres-17-${{ runner.os }}
|
||||
|
||||
- name: Pull and save PostgreSQL image
|
||||
if: steps.postgres-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker pull postgres:17
|
||||
docker save postgres:17 > /tmp/postgres-image.tar
|
||||
|
||||
- name: Load PostgreSQL image from cache
|
||||
if: steps.postgres-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/postgres-image.tar
|
||||
|
||||
- name: Cache LLDAP Docker image
|
||||
uses: actions/cache@v3
|
||||
id: lldap-cache
|
||||
with:
|
||||
path: /tmp/lldap-image.tar
|
||||
key: lldap-stable-${{ runner.os }}
|
||||
|
||||
- name: Pull and save LLDAP image
|
||||
if: steps.lldap-cache.outputs.cache-hit != 'true'
|
||||
run: |
|
||||
docker pull nitnelave/lldap:stable
|
||||
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
|
||||
|
||||
- name: Load LLDAP image from cache
|
||||
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/lldap-image.tar
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
|
||||
- name: Load Docker image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
|
||||
- name: Install test dependencies
|
||||
working-directory: ./tests
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: ./tests
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Run Docker Container with Postgres DB and LDAP
|
||||
working-directory: ./tests/setup
|
||||
run: |
|
||||
docker compose -f docker-compose-postgres.yml up -d
|
||||
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./tests
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: playwright-report-postgres
|
||||
path: frontend/tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
- name: Upload Backend Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: backend-postgres
|
||||
name: backend-${{ matrix.db }}
|
||||
path: /tmp/backend.log
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
27
.github/workflows/release.yml
vendored
27
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Release
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*.*.*"
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -16,15 +16,17 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "backend/go.mod"
|
||||
go-version-file: 'backend/go.mod'
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
@@ -62,13 +64,12 @@ jobs:
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
- name: Install frontend dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
- name: Build frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
run: pnpm --filter pocket-id-frontend build
|
||||
|
||||
- name: Build binaries
|
||||
run: sh scripts/development/build-binaries.sh
|
||||
- name: Build and push container image
|
||||
@@ -94,17 +95,17 @@ jobs:
|
||||
- name: Binary attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: "backend/.bin/pocket-id-**"
|
||||
subject-path: 'backend/.bin/pocket-id-**'
|
||||
- name: Container image attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Container image attestation (distroless)
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: Upload binaries to release
|
||||
@@ -121,6 +122,6 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
- name: Mark release as published
|
||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||
|
||||
38
.github/workflows/svelte-check.yml
vendored
38
.github/workflows/svelte-check.yml
vendored
@@ -4,21 +4,21 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
- 'frontend/src/**'
|
||||
- '.github/svelte-check-matcher.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/tsconfig.json'
|
||||
- 'frontend/svelte.config.js'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
- 'frontend/src/**'
|
||||
- '.github/svelte-check-matcher.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/tsconfig.json'
|
||||
- 'frontend/svelte.config.js'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -36,24 +36,26 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
- name: Build Pocket ID Frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
run: pnpm --filter pocket-id-frontend build
|
||||
|
||||
- name: Add svelte-check problem matcher
|
||||
run: echo "::add-matcher::.github/svelte-check-matcher.json"
|
||||
|
||||
- name: Run svelte-check
|
||||
working-directory: frontend
|
||||
run: npm run check
|
||||
run: pnpm --filter pocket-id-frontend check
|
||||
|
||||
136
CHANGELOG.md
136
CHANGELOG.md
@@ -1,3 +1,139 @@
|
||||
## [1.11.1](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.1) (2025-09-18)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- add missing translations ([8c9cac2](https://github.com/pocket-id/pocket-id/commit/8c9cac2655ddbe4872234a1b55fdd51d2f3ac31c))
|
||||
|
||||
## [1.11.0](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.0) (2025-09-18)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add CSP header ([#908](https://github.com/pocket-id/pocket-id/issues/908)) ([6215e1a](https://github.com/pocket-id/pocket-id/commit/6215e1ac01c03866f8b2e89ac084ddd6a3c3ac9e))
|
||||
* add custom base url ([#858](https://github.com/pocket-id/pocket-id/issues/858)) ([a3979f6](https://github.com/pocket-id/pocket-id/commit/a3979f63e07d418ee9eb1cb1abc37aede5799fc8))
|
||||
* add info box to app settings if UI config is disabled ([a1d8538](https://github.com/pocket-id/pocket-id/commit/a1d8538c64beb4d7e8559934985772fba27623ca))
|
||||
* add PWA support ([#938](https://github.com/pocket-id/pocket-id/issues/938)) ([5367463](https://github.com/pocket-id/pocket-id/commit/5367463239b354640fd65390bc409e4a0ac13fd1))
|
||||
* add support for `LOG_LEVEL` env variable ([#942](https://github.com/pocket-id/pocket-id/issues/942)) ([2d6d5df](https://github.com/pocket-id/pocket-id/commit/2d6d5df0e7f104a148fb4eeac89a2fbb7db8047a))
|
||||
* add user display name field ([#898](https://github.com/pocket-id/pocket-id/issues/898)) ([6837360](https://github.com/pocket-id/pocket-id/commit/68373604dd30065947226922233bc1e19e778b01))
|
||||
* allow uppercase usernames ([#958](https://github.com/pocket-id/pocket-id/issues/958)) ([0224949](https://github.com/pocket-id/pocket-id/commit/02249491f86c289adf596d9d9922dfa04779edee))
|
||||
* client_credentials flow support ([#901](https://github.com/pocket-id/pocket-id/issues/901)) ([901333f](https://github.com/pocket-id/pocket-id/commit/901333f7e43b4e925ed6dfd890dee2caa1947934))
|
||||
* return new id_token when using refresh token ([#925](https://github.com/pocket-id/pocket-id/issues/925)) ([307caaa](https://github.com/pocket-id/pocket-id/commit/307caaa3efbc966341b95ee4b5ff18c81ed98e54))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add validation for callback URLs ([#929](https://github.com/pocket-id/pocket-id/issues/929)) ([6c91474](https://github.com/pocket-id/pocket-id/commit/6c9147483c0a370e2b5011d13898279d2acc445d))
|
||||
* disable sign up options in UI if `UI_CONFIG_DISABLED` ([1d7cbc2](https://github.com/pocket-id/pocket-id/commit/1d7cbc2a4ecf352d46087f30b477f6bbaa23adf5))
|
||||
* ensure users imported from LDAP have fields validated ([#923](https://github.com/pocket-id/pocket-id/issues/923)) ([4215523](https://github.com/pocket-id/pocket-id/commit/42155238b750b015b0547294f397e1e285594e3e))
|
||||
* key-rotate doesn't work with database storage ([#940](https://github.com/pocket-id/pocket-id/issues/940)) ([c018f29](https://github.com/pocket-id/pocket-id/commit/c018f29ad7c61a3ef1b235b0d404a3a2024a26ca))
|
||||
* list items on previous page get unselected if other items selected on next page ([6c696b4](https://github.com/pocket-id/pocket-id/commit/6c696b46c8b60b3dc4af35c9c6cf1b8e1322f4cd))
|
||||
* make environment variables case insensitive where necessary ([#954](https://github.com/pocket-id/pocket-id/issues/954)) ([99f31a7](https://github.com/pocket-id/pocket-id/commit/99f31a7c26c63dec76682ddf450d88e6ee40876f)), closes [#935](https://github.com/pocket-id/pocket-id/issues/935)
|
||||
* my apps card shouldn't take full width if only one item exists ([e7e53a8](https://github.com/pocket-id/pocket-id/commit/e7e53a8b8c87bee922167d24556aef3ea219b1a2))
|
||||
* update localized name and description of ldap group name attribute ([#892](https://github.com/pocket-id/pocket-id/issues/892)) ([e88be7e](https://github.com/pocket-id/pocket-id/commit/e88be7e61a8aafabcae70adf9265023c50626705))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.1...v) (2025-08-27)
|
||||
|
||||
### Features
|
||||
|
||||
* redesigned sidebar with administrative dropdown ([#881](https://github.com/pocket-id/pocket-id/issues/881)) ([096d214](https://github.com/pocket-id/pocket-id/commit/096d214a88808848dae726b0ef4c9a9987185836))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* apps showed multiple times if user is in multiple groups ([641bbc9](https://github.com/pocket-id/pocket-id/commit/641bbc935191bad8afbfec90943fc3e9de7a0cb6))
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.0...v) (2025-08-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* sqlite migration drops allowed user groups ([d6d1a4c](https://github.com/pocket-id/pocket-id/commit/d6d1a4ced23886f255a9c2048d19ad3599a17f26))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.1...v) (2025-08-24)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* support automatic db migration rollbacks ([#874](https://github.com/pocket-id/pocket-id/issues/874)) ([c114a2e](https://github.com/pocket-id/pocket-id/commit/c114a2edaae4c007c75c34c02e8b0bb011845cae))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* don't force uuid for client id in postgres ([2ffc6ba](https://github.com/pocket-id/pocket-id/commit/2ffc6ba42af4742a13b77543142b66b3e826ab88))
|
||||
* ensure SQLite has a writable temporary directory ([#876](https://github.com/pocket-id/pocket-id/issues/876)) ([1f3550c](https://github.com/pocket-id/pocket-id/commit/1f3550c9bd3aafd3bd2272ef47f3ed8736037d81))
|
||||
* sort order incorrect for apps when using postgres ([d0392d2](https://github.com/pocket-id/pocket-id/commit/d0392d25edcaa5f3c7da2aad70febf63b47763fa))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.0...v) (2025-08-24)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* migration clears allowed users groups ([5971bfb](https://github.com/pocket-id/pocket-id/commit/5971bfbfa66ecfebf2b1c08d34fcbd8c18cdc046))
|
||||
* wrong column type for reauthentication tokens in Postgres ([#869](https://github.com/pocket-id/pocket-id/issues/869)) ([1283314](https://github.com/pocket-id/pocket-id/commit/1283314f776a0ba43be7d796e7e2243e31f860de))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.7.0...v) (2025-08-23)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add option to OIDC client to require re-authentication ([#747](https://github.com/pocket-id/pocket-id/issues/747)) ([0cb039d](https://github.com/pocket-id/pocket-id/commit/0cb039d35d49206011064e622f3bfd3d8f88720f))
|
||||
* allow custom client IDs ([#864](https://github.com/pocket-id/pocket-id/issues/864)) ([a5efb95](https://github.com/pocket-id/pocket-id/commit/a5efb9506582884c70b9b1fd737ebdd44b101b47))
|
||||
* display all accessible oidc clients in the dashboard ([#832](https://github.com/pocket-id/pocket-id/issues/832)) ([3188e92](https://github.com/pocket-id/pocket-id/commit/3188e92257afcaf7a16dd418e4c40626d7e1d034))
|
||||
* login code font change ([#851](https://github.com/pocket-id/pocket-id/issues/851)) ([d28bfac](https://github.com/pocket-id/pocket-id/commit/d28bfac81fc24ee79e4896538a616f0a89ab30a5))
|
||||
* **signup:** add default user groups and claims for new users ([#812](https://github.com/pocket-id/pocket-id/issues/812)) ([182d809](https://github.com/pocket-id/pocket-id/commit/182d8090286f9953171c6c410283be679889aca7))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* authorization can't be revoked ([0aab3f3](https://github.com/pocket-id/pocket-id/commit/0aab3f3c7ad8c1b14939de3ded60c9f201eab8fc))
|
||||
* delete webauthn session after login to prevent replay attacks ([fe003b9](https://github.com/pocket-id/pocket-id/commit/fe003b927ce7772692439992860c804de89ce424))
|
||||
* **deps:** bump rollup from 4.45.3 to 4.46.3 ([#845](https://github.com/pocket-id/pocket-id/issues/845)) ([b5e6371](https://github.com/pocket-id/pocket-id/commit/b5e6371eaaf3d9e85d8b05c457487c4425fa8381))
|
||||
* enable foreign key check for sqlite ([#863](https://github.com/pocket-id/pocket-id/issues/863)) ([625f235](https://github.com/pocket-id/pocket-id/commit/625f23574001ebd7074b8d98d448a2811847be16))
|
||||
* ferated identities can't be cleared ([24e2742](https://github.com/pocket-id/pocket-id/commit/24e274200fe4002d01c58cc3fa74094b598d7599))
|
||||
* for one-time access tokens and signup tokens, pass TTLs instead of absolute expiration date ([#855](https://github.com/pocket-id/pocket-id/issues/855)) ([7ab0fd3](https://github.com/pocket-id/pocket-id/commit/7ab0fd30286e6b67b5ce586484d82a20c42b471d))
|
||||
* ignore client secret if client is public ([#836](https://github.com/pocket-id/pocket-id/issues/836)) ([7b1f6b8](https://github.com/pocket-id/pocket-id/commit/7b1f6b88572bac1f3e838a9e904917fbd5fbdf61))
|
||||
* move audit log call before TX is committed ([#854](https://github.com/pocket-id/pocket-id/issues/854)) ([9339e88](https://github.com/pocket-id/pocket-id/commit/9339e88a5a26ff77a5e40149cbb1a5b339b7ec6a))
|
||||
* non admin users can't revoke oidc client but see edit link ([0e44f24](https://github.com/pocket-id/pocket-id/commit/0e44f245afcdf8179bf619613ca9ef4bffa176ca))
|
||||
* oidc client advanced options color ([fc0c99a](https://github.com/pocket-id/pocket-id/commit/fc0c99a232b0efb1a5b5d2c551102418b1080293))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.4...v) (2025-08-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add robots.txt to block indexing ([#806](https://github.com/pocket-id/pocket-id/issues/806)) ([06e1656](https://github.com/pocket-id/pocket-id/commit/06e1656923eb2f4531be497716f9147c09d60b65))
|
||||
* add support for `code_challenge_methods_supported` ([#794](https://github.com/pocket-id/pocket-id/issues/794)) ([d479817](https://github.com/pocket-id/pocket-id/commit/d479817b6a7ca4807b5de500b3ba713d436b0770))
|
||||
* Support OTel and JSON for logs (via log/slog) ([#760](https://github.com/pocket-id/pocket-id/issues/760)) ([78266e3](https://github.com/pocket-id/pocket-id/commit/78266e3e4cab2b23249c3baf20f4387d00eebd9e))
|
||||
* support reading secret env vars from _FILE ([#799](https://github.com/pocket-id/pocket-id/issues/799)) ([0a3b1c6](https://github.com/pocket-id/pocket-id/commit/0a3b1c653050f2237d30ec437c5de88baa704a25))
|
||||
* user application dashboard ([#727](https://github.com/pocket-id/pocket-id/issues/727)) ([484c2f6](https://github.com/pocket-id/pocket-id/commit/484c2f6ef20efc1fade1a41e2aeace54c7bb4f1b))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* admins can not delete or disable their own account ([f0c144c](https://github.com/pocket-id/pocket-id/commit/f0c144c51c635bc348222a00d3bc88bc4e0711ef))
|
||||
* authorization animation not working ([9ac5d51](https://github.com/pocket-id/pocket-id/commit/9ac5d5118710cad59c8c4ce7cef7ab09be3de664))
|
||||
* custom claims input suggestions instantly close after opening ([4d59e72](https://github.com/pocket-id/pocket-id/commit/4d59e7286666480e20c728787a95e82513509240))
|
||||
* delete WebAuthn registration session after use ([#783](https://github.com/pocket-id/pocket-id/issues/783)) ([c8478d7](https://github.com/pocket-id/pocket-id/commit/c8478d75bed7295625cd3cf62ef46fcd95902410))
|
||||
* set input type 'email' for email-based login ([#776](https://github.com/pocket-id/pocket-id/issues/776)) ([d541c9a](https://github.com/pocket-id/pocket-id/commit/d541c9ab4af8d7283891a80f886dd5d4ebc52f53))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.3...v) (2025-07-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* migration fails on postgres ([#762](https://github.com/pocket-id/pocket-id/issues/762)) ([35d5f88](https://github.com/pocket-id/pocket-id/commit/35d5f887ce7c88933d7e4c2f0acd2aeedd18c214))
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.2...v) (2025-07-21)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* allow passkey names up to 50 characters ([b03e91b](https://github.com/pocket-id/pocket-id/commit/b03e91b6530c2393ad20ac49aa2cb2b4962651b2))
|
||||
* ensure user inputs are normalized ([#724](https://github.com/pocket-id/pocket-id/issues/724)) ([7b4ccd1](https://github.com/pocket-id/pocket-id/commit/7b4ccd1f306f4882c52fe30133fcda114ef0d18b))
|
||||
* show rename and delete buttons for passkeys without hovering over the row ([2952b15](https://github.com/pocket-id/pocket-id/commit/2952b1575542ecd0062fe740e2d6a3caad05190d))
|
||||
* use object-contain for images on oidc-client list ([d3bc179](https://github.com/pocket-id/pocket-id/commit/d3bc1797b65ec8bc9201c55d06f3612093f3a873))
|
||||
* use user-agent for identifying known device signins ([ef1d599](https://github.com/pocket-id/pocket-id/commit/ef1d5996624fc534190f80a26f2c48bbad206f49))
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.6.1...v) (2025-07-09)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* ensure confirmation dialog shows on top of other components ([f103a54](https://github.com/pocket-id/pocket-id/commit/f103a547904070c5b192e519c8b5a8fed9d80e96))
|
||||
* login failures on Postgres when IP is null ([#737](https://github.com/pocket-id/pocket-id/issues/737)) ([e1de593](https://github.com/pocket-id/pocket-id/commit/e1de593dcd30b7b04da3b003455134992b702595))
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.5.0...v) (2025-07-06)
|
||||
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Before you submit the pull request for review please ensure that
|
||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||
|
||||
- Your pull request has a detailed description
|
||||
- You run `npm run format` to format the code
|
||||
- You run `pnpm format` to format the code
|
||||
|
||||
## Development Environment
|
||||
|
||||
@@ -52,7 +52,7 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
|
||||
If you don't use Dev Containers, you need to install the following tools manually:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 22
|
||||
- [Go](https://golang.org/doc/install) >= 1.24
|
||||
- [Go](https://golang.org/doc/install) >= 1.25
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### 2. Setup
|
||||
@@ -69,10 +69,10 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
|
||||
|
||||
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
|
||||
|
||||
1. Open the `frontend` folder
|
||||
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
1. Open the `pocket-id` project folder
|
||||
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
|
||||
3. Install the dependencies with `pnpm install`
|
||||
4. Start the frontend with `pnpm dev`
|
||||
|
||||
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
|
||||
|
||||
@@ -84,11 +84,13 @@ If you are contributing to a new feature please ensure that you add tests for it
|
||||
|
||||
The tests can be run like this:
|
||||
|
||||
1. Visit the setup folder by running `cd tests/setup`
|
||||
1. Install the dependencies from the root of the project `pnpm install`
|
||||
|
||||
2. Start the test environment by running `docker compose up -d --build`
|
||||
2. Visit the setup folder by running `cd tests/setup`
|
||||
|
||||
3. Go back to the test folder by running `cd ..`
|
||||
4. Run the tests with `npx playwright test`
|
||||
3. Start the test environment by running `docker compose up -d --build`
|
||||
|
||||
4. Go back to the test folder by running `cd ..`
|
||||
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
|
||||
|
||||
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
|
||||
|
||||
22
Dockerfile
22
Dockerfile
@@ -5,21 +5,27 @@ ARG BUILD_TAGS=""
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
COPY ./frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY ./frontend ./
|
||||
RUN BUILD_OUTPUT_PATH=dist npm run build
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY frontend/package.json ./frontend/
|
||||
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
COPY ./frontend ./frontend/
|
||||
|
||||
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.24-alpine AS backend-builder
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
ARG BUILD_TAGS
|
||||
WORKDIR /build
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
COPY ./backend ./
|
||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
||||
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
|
||||
COPY .version .version
|
||||
|
||||
WORKDIR /build/cmd
|
||||
@@ -30,7 +36,7 @@ RUN VERSION=$(cat /build/.version) \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
|
||||
-trimpath \
|
||||
-o /build/pocket-id-backend \
|
||||
-o /build/pocket-id \
|
||||
.
|
||||
|
||||
# Stage 3: Production Image
|
||||
@@ -39,7 +45,7 @@ WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl su-exec
|
||||
|
||||
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
|
||||
COPY --from=backend-builder /build/pocket-id /app/pocket-id
|
||||
COPY ./scripts/docker /app/docker
|
||||
|
||||
RUN chmod +x /app/pocket-id && \
|
||||
|
||||
@@ -61,4 +61,4 @@ formatters:
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
- examples$
|
||||
@@ -3,8 +3,10 @@
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,11 +14,55 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
)
|
||||
|
||||
//go:embed all:dist/*
|
||||
var frontendFS embed.FS
|
||||
|
||||
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
|
||||
var writeIndexFn func(w io.Writer, nonce string) error
|
||||
|
||||
func init() {
|
||||
const scriptTag = "<script>"
|
||||
|
||||
// Read the index.html from the bundle
|
||||
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
|
||||
if iErr != nil {
|
||||
panic(fmt.Errorf("failed to read index.html: %w", iErr))
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// If there's no nonce, write the index as-is
|
||||
if nonce == "" {
|
||||
_, err = w.Write(index)
|
||||
return err
|
||||
}
|
||||
|
||||
// We have a nonce, so first write the index until the <script> tag
|
||||
// Then we write the modified script tag
|
||||
// Finally, the rest of the index
|
||||
_, err = w.Write(index[0:idx])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, 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
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
@@ -27,13 +73,39 @@ func RegisterFrontend(router *gin.Engine) error {
|
||||
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
||||
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
// Try to serve the requested file
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
|
||||
// File doesn't exist, serve index.html instead
|
||||
c.Request.URL.Path = "/"
|
||||
|
||||
if strings.HasPrefix(path, "api/") {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
|
||||
return
|
||||
}
|
||||
|
||||
// If path is / or does not exist, serve index.html
|
||||
if path == "" {
|
||||
path = "index.html"
|
||||
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
|
||||
path = "index.html"
|
||||
}
|
||||
|
||||
if path == "index.html" {
|
||||
nonce := middleware.GetCSPNonce(c)
|
||||
|
||||
// Do not cache the HTML shell, as it embeds a per-request nonce
|
||||
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||
c.Header("Cache-Control", "no-store")
|
||||
c.Status(http.StatusOK)
|
||||
|
||||
err = writeIndexFn(c.Writer, nonce)
|
||||
if err != nil {
|
||||
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Serve other static assets with caching
|
||||
c.Request.URL.Path = "/" + path
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
|
||||
101
backend/go.mod
101
backend/go.mod
@@ -1,98 +1,109 @@
|
||||
module github.com/pocket-id/pocket-id/backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.2
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.21.3
|
||||
github.com/fxamacker/cbor/v2 v2.7.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/gin-contrib/slog v1.1.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.15.0
|
||||
github.com/go-co-op/gocron/v2 v2.16.3
|
||||
github.com/go-ldap/ldap/v3 v3.4.10
|
||||
github.com/go-playground/validator/v10 v10.25.0
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-uuid v1.0.3
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.10
|
||||
github.com/lmittmann/tint v1.1.2
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
|
||||
go.opentelemetry.io/otel v1.35.0
|
||||
go.opentelemetry.io/otel/metric v1.35.0
|
||||
go.opentelemetry.io/otel v1.37.0
|
||||
go.opentelemetry.io/otel/log v0.13.0
|
||||
go.opentelemetry.io/otel/metric v1.37.0
|
||||
go.opentelemetry.io/otel/sdk v1.35.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
||||
go.opentelemetry.io/otel/trace v1.35.0
|
||||
golang.org/x/crypto v0.37.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/time v0.9.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/gorm v1.25.12
|
||||
go.opentelemetry.io/otel/trace v1.37.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/sync v0.16.0
|
||||
golang.org/x/text v0.28.0
|
||||
golang.org/x/time v0.12.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.10 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/disintegration/gift v1.1.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.16 // indirect
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
@@ -103,7 +114,8 @@ require (
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
|
||||
@@ -118,23 +130,20 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.10.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sync v0.14.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.24.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.27.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/grpc v1.71.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.65.6 // indirect
|
||||
modernc.org/libc v1.66.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.10.0 // indirect
|
||||
modernc.org/sqlite v1.37.0 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
)
|
||||
|
||||
229
backend/go.sum
229
backend/go.sum
@@ -8,30 +8,28 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
|
||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/sonic v1.12.10 h1:uVCQr6oS5669E9ZVW0HyksTLfNS7Q/9hV6IVS4nEMsI=
|
||||
github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
|
||||
github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
|
||||
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
|
||||
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||
@@ -54,27 +52,29 @@ github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGV
|
||||
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
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/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
|
||||
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -83,27 +83,35 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
|
||||
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
|
||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
@@ -127,8 +135,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/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -157,10 +165,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -169,18 +175,22 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3 h1:94HXkVLxkZO9vJI/w2u1T0DAoprShFd13xtnSINtDWs=
|
||||
github.com/lestrrat-go/blackmagic v1.0.3/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2 h1:SDxjGoH7qj0nBXVrcrxX8eD94wEnjR+EEuqqmeqQYlY=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta2/go.mod h1:Nwo81sMxE0DcvTB+rJyynNhv/DUu2yZErV7sscw9pHE=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.1 h1:fH3T748FCMbXoF9UXXNS9i0q6PpYyJZK/rKSbkt2guY=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.1/go.mod h1:XP2WqxMOSzHSyf3pfibCcfsLqbomxakAnNqiuaH8nwo=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
@@ -208,10 +218,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
|
||||
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -240,7 +252,6 @@ github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -248,18 +259,21 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
|
||||
@@ -268,8 +282,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
|
||||
@@ -292,47 +306,49 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
|
||||
go.opentelemetry.io/otel/log v0.10.0 h1:1CXmspaRITvFcjA4kyVszuG4HjA61fPDxMb7q3BuyF0=
|
||||
go.opentelemetry.io/otel/log v0.10.0/go.mod h1:PbVdm9bXKku/gL0oFfUF4wwsQsOPlpo4VEqjvxih+FM=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
|
||||
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -344,8 +360,11 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -353,8 +372,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -367,8 +386,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -379,6 +398,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
@@ -387,27 +407,29 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -415,32 +437,33 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
|
||||
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
|
||||
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
|
||||
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -12,53 +16,121 @@ import (
|
||||
)
|
||||
|
||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||
func initApplicationImages() {
|
||||
func initApplicationImages() error {
|
||||
// Images that are built into the Pocket ID binary
|
||||
builtInImageHashes := getBuiltInImageHashes()
|
||||
|
||||
// Previous versions of images
|
||||
// If these are found, they are deleted
|
||||
legacyImageHashes := imageHashMap{
|
||||
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
|
||||
}
|
||||
|
||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||
|
||||
sourceFiles, err := resources.FS.ReadDir("images")
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
return fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
destinationFiles, err := os.ReadDir(dirPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
return fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
destinationFilesMap := make(map[string]bool, len(destinationFiles))
|
||||
for _, f := range destinationFiles {
|
||||
name := f.Name()
|
||||
destFilePath := filepath.Join(dirPath, name)
|
||||
|
||||
h, err := utils.CreateSha256FileHash(destFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get hash for file '%s': %w", name, err)
|
||||
}
|
||||
|
||||
// Check if the file is a legacy one - if so, delete it
|
||||
if legacyImageHashes.Contains(h) {
|
||||
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
|
||||
err = os.Remove(destFilePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the file is a built-in one and save it in the map
|
||||
destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h)
|
||||
}
|
||||
|
||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||
for _, sourceFile := range sourceFiles {
|
||||
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
|
||||
// Skip if it's a directory
|
||||
if sourceFile.IsDir() {
|
||||
continue
|
||||
}
|
||||
srcFilePath := path.Join("images", sourceFile.Name())
|
||||
destFilePath := path.Join(dirPath, sourceFile.Name())
|
||||
|
||||
name := sourceFile.Name()
|
||||
srcFilePath := filepath.Join("images", name)
|
||||
destFilePath := filepath.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 imageAlreadyExists(sourceFile, destinationFilesMap) {
|
||||
continue
|
||||
}
|
||||
|
||||
slog.Info("Writing new application image", slog.String("name", name))
|
||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error copying file: %v", err)
|
||||
return fmt.Errorf("failed to copy file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getBuiltInImageHashes() imageHashMap {
|
||||
return imageHashMap{
|
||||
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
|
||||
"favicon.ico": mustDecodeHex("70f9c4b6bd4781ade5fc96958b1267511751e91957f83c2354fb880b35ec890a"),
|
||||
"logo.svg": mustDecodeHex("f1e60707df9784152ce0847e3eb59cb68b9015f918ff160376c27ebff1eda796"),
|
||||
"logoDark.svg": mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
|
||||
"logoLight.svg": mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
|
||||
}
|
||||
}
|
||||
|
||||
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
|
||||
for _, destinationFile := range destinationFiles {
|
||||
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
|
||||
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
|
||||
type imageHashMap map[string][]byte
|
||||
|
||||
if sourceFileWithoutExtension == destinationFileWithoutExtension {
|
||||
func (m imageHashMap) Contains(target []byte) bool {
|
||||
if len(target) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, h := range m {
|
||||
if bytes.Equal(h, target) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
func imageAlreadyExists(sourceFile fs.DirEntry, destinationFiles map[string]bool) bool {
|
||||
sourceFileWithoutExtension := getImageNameWithoutExtension(sourceFile.Name())
|
||||
_, ok := destinationFiles[sourceFileWithoutExtension]
|
||||
return ok
|
||||
}
|
||||
|
||||
func getImageNameWithoutExtension(fileName string) string {
|
||||
idx := strings.LastIndexByte(fileName, '.')
|
||||
if idx < 1 {
|
||||
// No dot found, or fileName starts with a dot
|
||||
return fileName
|
||||
}
|
||||
|
||||
return fileName[:idx]
|
||||
}
|
||||
|
||||
func mustDecodeHex(str string) []byte {
|
||||
b, err := hex.DecodeString(str)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestGetBuiltInImageData(t *testing.T) {
|
||||
// Get the built-in image data map
|
||||
builtInImages := getBuiltInImageHashes()
|
||||
|
||||
// Read the actual images directory from disk
|
||||
imagesDir := filepath.Join("..", "..", "resources", "images")
|
||||
actualFiles, err := os.ReadDir(imagesDir)
|
||||
require.NoError(t, err, "Failed to read images directory")
|
||||
|
||||
// Create a map of actual files for comparison
|
||||
actualFilesMap := make(map[string]struct{})
|
||||
|
||||
// Validate each actual file exists in the built-in data with correct hash
|
||||
for _, file := range actualFiles {
|
||||
fileName := file.Name()
|
||||
if file.IsDir() || strings.HasPrefix(fileName, ".") {
|
||||
continue
|
||||
}
|
||||
|
||||
actualFilesMap[fileName] = struct{}{}
|
||||
|
||||
// Check if the file exists in the built-in data
|
||||
builtInHash, exists := builtInImages[fileName]
|
||||
assert.True(t, exists, "File %s exists in images directory but not in getBuiltInImageData map", fileName)
|
||||
|
||||
if !exists {
|
||||
continue
|
||||
}
|
||||
|
||||
filePath := filepath.Join(imagesDir, fileName)
|
||||
|
||||
// Validate SHA256 hash
|
||||
actualHash, err := utils.CreateSha256FileHash(filePath)
|
||||
require.NoError(t, err, "Failed to compute hash for %s", fileName)
|
||||
assert.Equal(t, actualHash, builtInHash, "SHA256 hash mismatch for file %s", fileName)
|
||||
}
|
||||
|
||||
// Ensure the built-in data doesn't have extra files that don't exist in the directory
|
||||
for fileName := range builtInImages {
|
||||
_, exists := actualFilesMap[fileName]
|
||||
assert.True(t, exists, "File %s exists in getBuiltInImageData map but not in images directory", fileName)
|
||||
}
|
||||
|
||||
// Ensure we have at least some files (sanity check)
|
||||
assert.NotEmpty(t, actualFilesMap, "Images directory should contain at least one file")
|
||||
assert.Len(t, actualFilesMap, len(builtInImages), "Number of files in directory should match number in built-in data map")
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
@@ -14,16 +14,23 @@ import (
|
||||
)
|
||||
|
||||
func Bootstrap(ctx context.Context) error {
|
||||
initApplicationImages()
|
||||
|
||||
// Initialize the tracer and metrics exporter
|
||||
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||
// Initialize the observability stack, including the logger, distributed tracing, and metrics
|
||||
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
|
||||
}
|
||||
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||
|
||||
err = initApplicationImages()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
db := NewDatabase()
|
||||
db, err := NewDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
// Create all services
|
||||
svc, err := initServices(ctx, db, httpClient)
|
||||
@@ -62,7 +69,7 @@ func Bootstrap(ctx context.Context) error {
|
||||
NewServiceRunner(shutdownFns...).
|
||||
Run(shutdownCtx) //nolint:contextcheck
|
||||
if err != nil {
|
||||
log.Printf("Error shutting down services: %v", err)
|
||||
slog.Error("Error shutting down services", slog.Any("error", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,9 +3,10 @@ package bootstrap
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -14,51 +15,58 @@ import (
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
slogGorm "github.com/orandin/slog-gorm"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func NewDatabase() (db *gorm.DB) {
|
||||
db, err := connectDatabase()
|
||||
func NewDatabase() (db *gorm.DB, err error) {
|
||||
db, err = connectDatabase()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get sql.DB: %v", err)
|
||||
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Choose the correct driver for the database provider
|
||||
var driver database.Driver
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
|
||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
|
||||
NoTxWrap: true,
|
||||
})
|
||||
case common.DbProviderPostgres:
|
||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||
default:
|
||||
// Should never happen at this point
|
||||
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create migration driver: %v", err)
|
||||
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := migrateDatabase(driver); err != nil {
|
||||
log.Fatalf("failed to run migrations: %v", err)
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func migrateDatabase(driver database.Driver) error {
|
||||
// Use the embedded migrations
|
||||
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
|
||||
// Embedded migrations via iofs
|
||||
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||
source, err := iofs.New(resources.FS, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||
}
|
||||
@@ -68,14 +76,66 @@ func migrateDatabase(driver database.Driver) error {
|
||||
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||
requiredVersion, err := getRequiredMigrationVersion(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last migration version: %w", err)
|
||||
}
|
||||
|
||||
currentVersion, _, _ := m.Version()
|
||||
if currentVersion > requiredVersion {
|
||||
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
|
||||
if !common.EnvConfig.AllowDowngrade {
|
||||
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
|
||||
}
|
||||
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||
return migrateDatabaseFromGitHub(driver, requiredVersion)
|
||||
}
|
||||
|
||||
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply embedded migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
|
||||
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
|
||||
func getRequiredMigrationVersion(path string) (uint, error) {
|
||||
entries, err := resources.FS.ReadDir(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read migration directory: %w", err)
|
||||
}
|
||||
|
||||
var maxVersion uint
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
var version uint
|
||||
n, err := fmt.Sscanf(name, "%d_", &version)
|
||||
if err == nil && n == 1 {
|
||||
if version > maxVersion {
|
||||
maxVersion = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxVersion, nil
|
||||
}
|
||||
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
@@ -85,13 +145,20 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||
}
|
||||
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
|
||||
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
|
||||
}
|
||||
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||
|
||||
sqliteutil.RegisterSqliteFunctions()
|
||||
|
||||
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||
err = ensureSqliteTempDir(filepath.Dir(dbPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialector = sqlite.Open(connString)
|
||||
case common.DbProviderPostgres:
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
@@ -105,38 +172,68 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
for i := 1; i <= 3; i++ {
|
||||
db, err = gorm.Open(dialector, &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: getLogger(),
|
||||
Logger: getGormLogger(),
|
||||
})
|
||||
if err == nil {
|
||||
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
|
||||
return db, nil
|
||||
}
|
||||
|
||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
||||
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
|
||||
if !strings.HasPrefix(connString, "file:") {
|
||||
connString = "file:" + connString
|
||||
}
|
||||
|
||||
// Check if we're using an in-memory database
|
||||
isMemoryDB := isSqliteInMemory(connString)
|
||||
|
||||
// Parse the connection string
|
||||
connStringUrl, err := url.Parse(connString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Convert options for the old SQLite driver to the new one
|
||||
convertSqlitePragmaArgs(connStringUrl)
|
||||
|
||||
// Add the default and required params
|
||||
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Get the absolute path to the database
|
||||
// Here, we know for a fact that the ? is present
|
||||
parsedConnString = connStringUrl.String()
|
||||
idx := strings.IndexRune(parsedConnString, '?')
|
||||
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
|
||||
}
|
||||
|
||||
return parsedConnString, dbPath, nil
|
||||
}
|
||||
|
||||
// The official C implementation of SQLite allows some additional properties in the connection string
|
||||
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
|
||||
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
|
||||
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
|
||||
func parseSqliteConnectionString(connString string) (string, error) {
|
||||
if !strings.HasPrefix(connString, "file:") {
|
||||
connString = "file:" + connString
|
||||
}
|
||||
|
||||
connStringUrl, err := url.Parse(connString)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Note this function updates connStringUrl.
|
||||
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
|
||||
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
|
||||
// This only includes a subset of options, excluding those that are not relevant to us
|
||||
qs := make(url.Values, len(connStringUrl.Query()))
|
||||
for k, v := range connStringUrl.Query() {
|
||||
switch k {
|
||||
switch strings.ToLower(k) {
|
||||
case "_auto_vacuum", "_vacuum":
|
||||
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
|
||||
case "_busy_timeout", "_timeout":
|
||||
@@ -157,29 +254,187 @@ func parseSqliteConnectionString(connString string) (string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update the connStringUrl object
|
||||
connStringUrl.RawQuery = qs.Encode()
|
||||
|
||||
return connStringUrl.String(), nil
|
||||
}
|
||||
|
||||
func getLogger() logger.Interface {
|
||||
isProduction := common.EnvConfig.AppEnv == "production"
|
||||
// Adds the default (and some required) parameters to the SQLite connection string.
|
||||
// Note this function updates connStringUrl.
|
||||
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
|
||||
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
|
||||
// Copyright (C) 2023 The Dapr Authors
|
||||
// License: Apache2
|
||||
const defaultBusyTimeout = 2500 * time.Millisecond
|
||||
|
||||
var logLevel logger.LogLevel
|
||||
if isProduction {
|
||||
logLevel = logger.Error
|
||||
} else {
|
||||
logLevel = logger.Info
|
||||
// Get the "query string" from the connection string if present
|
||||
qs := connStringUrl.Query()
|
||||
if len(qs) == 0 {
|
||||
qs = make(url.Values, 2)
|
||||
}
|
||||
|
||||
return logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logLevel,
|
||||
IgnoreRecordNotFoundError: isProduction,
|
||||
ParameterizedQueries: isProduction,
|
||||
Colorful: !isProduction,
|
||||
},
|
||||
)
|
||||
// If the database is in-memory, we must ensure that cache=shared is set
|
||||
if isMemoryDB {
|
||||
qs["cache"] = []string{"shared"}
|
||||
}
|
||||
|
||||
// Check if the database is read-only or immutable
|
||||
isReadOnly := false
|
||||
if len(qs["mode"]) > 0 {
|
||||
// Keep the first value only
|
||||
qs["mode"] = []string{
|
||||
strings.ToLower(qs["mode"][0]),
|
||||
}
|
||||
if qs["mode"][0] == "ro" {
|
||||
isReadOnly = true
|
||||
}
|
||||
}
|
||||
if len(qs["immutable"]) > 0 {
|
||||
// Keep the first value only
|
||||
qs["immutable"] = []string{
|
||||
strings.ToLower(qs["immutable"][0]),
|
||||
}
|
||||
if qs["immutable"][0] == "1" {
|
||||
isReadOnly = true
|
||||
}
|
||||
}
|
||||
|
||||
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
|
||||
if len(qs["_txlock"]) > 0 {
|
||||
// Keep the first value only
|
||||
qs["_txlock"] = []string{
|
||||
strings.ToLower(qs["_txlock"][0]),
|
||||
}
|
||||
if qs["_txlock"][0] != "immediate" {
|
||||
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
|
||||
}
|
||||
} else {
|
||||
qs["_txlock"] = []string{"immediate"}
|
||||
}
|
||||
|
||||
// Add pragma values
|
||||
var hasBusyTimeout, hasJournalMode bool
|
||||
if len(qs["_pragma"]) == 0 {
|
||||
qs["_pragma"] = make([]string, 0, 3)
|
||||
} else {
|
||||
for _, p := range qs["_pragma"] {
|
||||
p = strings.ToLower(p)
|
||||
switch {
|
||||
case strings.HasPrefix(p, "busy_timeout"):
|
||||
hasBusyTimeout = true
|
||||
case strings.HasPrefix(p, "journal_mode"):
|
||||
hasJournalMode = true
|
||||
case strings.HasPrefix(p, "foreign_keys"):
|
||||
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasBusyTimeout {
|
||||
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
|
||||
}
|
||||
if !hasJournalMode {
|
||||
switch {
|
||||
case isMemoryDB:
|
||||
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
|
||||
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
|
||||
case isReadOnly:
|
||||
// Set the journaling mode to "DELETE" (the default) if the database is read-only
|
||||
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
|
||||
default:
|
||||
// Enable WAL
|
||||
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully enable foreign keys
|
||||
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
|
||||
|
||||
// Update the connStringUrl object
|
||||
connStringUrl.RawQuery = qs.Encode()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSqliteInMemory returns true if the connection string is for an in-memory database.
|
||||
func isSqliteInMemory(connString string) bool {
|
||||
lc := strings.ToLower(connString)
|
||||
|
||||
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
|
||||
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Another way is to pass "mode=memory" in the "query string"
|
||||
idx := strings.IndexRune(lc, '?')
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
qs, _ := url.ParseQuery(lc[(idx + 1):])
|
||||
|
||||
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
|
||||
}
|
||||
|
||||
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
|
||||
// The default directory may not be writable when using a container with a read-only root file system
|
||||
// See: https://www.sqlite.org/tempfiles.html
|
||||
func ensureSqliteTempDir(dbPath string) error {
|
||||
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
|
||||
//
|
||||
// - The SQLITE_TMPDIR environment variable
|
||||
// - The TMPDIR environment variable
|
||||
// - /var/tmp
|
||||
// - /usr/tmp
|
||||
// - /tmp
|
||||
//
|
||||
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
|
||||
//
|
||||
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
|
||||
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
|
||||
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
|
||||
ok, err := utils.IsWritableDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
|
||||
}
|
||||
if ok {
|
||||
// We found a folder that's writable
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
|
||||
err := os.Setenv("SQLITE_TMPDIR", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getGormLogger() gormLogger.Interface {
|
||||
loggerOpts := make([]slogGorm.Option, 0, 5)
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.WithSlowThreshold(200*time.Millisecond),
|
||||
slogGorm.WithErrorField("error"),
|
||||
)
|
||||
|
||||
if common.EnvConfig.LogLevel == "debug" {
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
|
||||
slogGorm.WithRecordNotFoundError(),
|
||||
slogGorm.WithTraceAll(),
|
||||
)
|
||||
|
||||
} else {
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
|
||||
slogGorm.WithIgnoreTrace(),
|
||||
)
|
||||
}
|
||||
|
||||
return slogGorm.New(loggerOpts...)
|
||||
}
|
||||
|
||||
@@ -8,23 +8,93 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParseSqliteConnectionString(t *testing.T) {
|
||||
func TestIsSqliteInMemory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
expectedError bool
|
||||
name string
|
||||
connStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "memory database with :memory:",
|
||||
connStr: ":memory:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with file::memory:",
|
||||
connStr: "file::memory:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with :MEMORY: (uppercase)",
|
||||
connStr: ":MEMORY:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with FILE::MEMORY: (uppercase)",
|
||||
connStr: "FILE::MEMORY:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with mixed case",
|
||||
connStr: ":Memory:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "has mode=memory",
|
||||
connStr: "file:data?mode=memory",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "file database",
|
||||
connStr: "data.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "file database with path",
|
||||
connStr: "/path/to/data.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "file database with file: prefix",
|
||||
connStr: "file:data.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
connStr: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "string containing memory but not at start",
|
||||
connStr: "data:memory:.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "has mode=ro",
|
||||
connStr: "file:data?mode=ro",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isSqliteInMemory(tt.connStr)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertSqlitePragmaArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic file path",
|
||||
input: "file:test.db",
|
||||
expected: "file:test.db",
|
||||
},
|
||||
{
|
||||
name: "adds file: prefix if missing",
|
||||
input: "test.db",
|
||||
expected: "file:test.db",
|
||||
},
|
||||
{
|
||||
name: "converts _busy_timeout to pragma",
|
||||
input: "file:test.db?_busy_timeout=5000",
|
||||
@@ -100,46 +170,161 @@ func TestParseSqliteConnectionString(t *testing.T) {
|
||||
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
|
||||
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resultURL, _ := url.Parse(tt.input)
|
||||
convertSqlitePragmaArgs(resultURL)
|
||||
|
||||
// Parse both URLs to compare components independently
|
||||
expectedURL, err := url.Parse(tt.expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare scheme and path components
|
||||
compareQueryStrings(t, expectedURL, resultURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddSqliteDefaultParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
isMemoryDB bool
|
||||
expected string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "invalid URL format",
|
||||
input: "file:invalid#$%^&*@test.db",
|
||||
expectedError: true,
|
||||
name: "basic file database",
|
||||
input: "file:test.db",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "in-memory database",
|
||||
input: "file::memory:",
|
||||
isMemoryDB: true,
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||
},
|
||||
{
|
||||
name: "read-only database with mode=ro",
|
||||
input: "file:test.db?mode=ro",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||
},
|
||||
{
|
||||
name: "immutable database",
|
||||
input: "file:test.db?immutable=1",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||
},
|
||||
{
|
||||
name: "database with existing _txlock",
|
||||
input: "file:test.db?_txlock=deferred",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
|
||||
},
|
||||
{
|
||||
name: "database with existing busy_timeout pragma",
|
||||
input: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "database with existing journal_mode pragma",
|
||||
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "database with forbidden foreign_keys pragma",
|
||||
input: "file:test.db?_pragma=foreign_keys%280%29",
|
||||
isMemoryDB: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "database with multiple existing pragmas",
|
||||
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "in-memory database with cache already set",
|
||||
input: "file::memory:?cache=private",
|
||||
isMemoryDB: true,
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||
},
|
||||
{
|
||||
name: "database with mode=rw (not read-only)",
|
||||
input: "file:test.db?mode=rw",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
|
||||
},
|
||||
{
|
||||
name: "database with immutable=0 (not immutable)",
|
||||
input: "file:test.db?immutable=0",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
|
||||
},
|
||||
{
|
||||
name: "database with mixed case mode=RO",
|
||||
input: "file:test.db?mode=RO",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||
},
|
||||
{
|
||||
name: "database with mixed case immutable=1",
|
||||
input: "file:test.db?immutable=1",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||
},
|
||||
{
|
||||
name: "complex database configuration",
|
||||
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := parseSqliteConnectionString(tt.input)
|
||||
resultURL, err := url.Parse(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
if tt.expectedError {
|
||||
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse both URLs to compare components independently
|
||||
expectedURL, err := url.Parse(tt.expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
resultURL, err := url.Parse(result)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare scheme and path components
|
||||
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
|
||||
assert.Equal(t, expectedURL.Path, resultURL.Path)
|
||||
|
||||
// Compare query parameters regardless of order
|
||||
expectedQuery := expectedURL.Query()
|
||||
resultQuery := resultURL.Query()
|
||||
|
||||
assert.Len(t, expectedQuery, len(resultQuery))
|
||||
|
||||
for key, expectedValues := range expectedQuery {
|
||||
resultValues, ok := resultQuery[key]
|
||||
_ = assert.True(t, ok) &&
|
||||
assert.ElementsMatch(t, expectedValues, resultValues)
|
||||
}
|
||||
compareQueryStrings(t, expectedURL, resultURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
|
||||
t.Helper()
|
||||
|
||||
// Compare scheme and path components
|
||||
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
|
||||
assert.Equal(t, expectedURL.Path, resultURL.Path)
|
||||
|
||||
// Compare query parameters regardless of order
|
||||
expectedQuery := expectedURL.Query()
|
||||
resultQuery := resultURL.Query()
|
||||
|
||||
assert.Len(t, expectedQuery, len(resultQuery))
|
||||
|
||||
for key, expectedValues := range expectedQuery {
|
||||
resultValues, ok := resultQuery[key]
|
||||
_ = assert.True(t, ok) &&
|
||||
assert.ElementsMatch(t, expectedValues, resultValues)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
@@ -18,7 +19,8 @@ func init() {
|
||||
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to initialize test service: %v", err)
|
||||
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
203
backend/internal/bootstrap/observability_boostrap.go
Normal file
203
backend/internal/bootstrap/observability_boostrap.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
|
||||
"github.com/lmittmann/tint"
|
||||
"github.com/mattn/go-isatty"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
globallog "go.opentelemetry.io/otel/log/global"
|
||||
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func defaultResource() (*resource.Resource, error) {
|
||||
return resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewSchemaless(
|
||||
semconv.ServiceName(common.Name),
|
||||
semconv.ServiceVersion(common.Version),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
|
||||
resource, err := defaultResource()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
|
||||
}
|
||||
|
||||
shutdownFns = make([]utils.Service, 0, 2)
|
||||
|
||||
httpClient = &http.Client{}
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Indicates a development-time error
|
||||
panic("Default transport is not of type *http.Transport")
|
||||
}
|
||||
httpClient.Transport = defaultTransport.Clone()
|
||||
|
||||
// Logging
|
||||
err = initOtelLogging(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Tracing
|
||||
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if tracingShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, tracingShutdownFn)
|
||||
}
|
||||
|
||||
// Metrics
|
||||
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, resource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if metricsShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, metricsShutdownFn)
|
||||
}
|
||||
|
||||
return shutdownFns, httpClient, nil
|
||||
}
|
||||
|
||||
func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
|
||||
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
|
||||
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
|
||||
os.Setenv("OTEL_LOGS_EXPORTER", "none")
|
||||
}
|
||||
exp, err := autoexport.NewLogExporter(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
|
||||
}
|
||||
|
||||
level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
|
||||
|
||||
// Create the handler
|
||||
var handler slog.Handler
|
||||
if common.EnvConfig.LogJSON {
|
||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
} else {
|
||||
handler = tint.NewHandler(os.Stdout, &tint.Options{
|
||||
TimeFormat: time.Stamp,
|
||||
Level: level,
|
||||
NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
|
||||
})
|
||||
}
|
||||
|
||||
// Create the logger provider
|
||||
provider := sdklog.NewLoggerProvider(
|
||||
sdklog.WithProcessor(
|
||||
sdklog.NewBatchProcessor(exp),
|
||||
),
|
||||
sdklog.WithResource(resource),
|
||||
)
|
||||
|
||||
// Set the logger provider globally
|
||||
globallog.SetLoggerProvider(provider)
|
||||
|
||||
// Wrap the handler in a "fanout" one
|
||||
handler = utils.LogFanoutHandler{
|
||||
handler,
|
||||
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
|
||||
}
|
||||
|
||||
// Set the default slog to send logs to OTel and add the app name
|
||||
log := slog.New(handler).
|
||||
With(slog.String("app", common.Name)).
|
||||
With(slog.String("version", common.Version))
|
||||
slog.SetDefault(log)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
|
||||
if !traces {
|
||||
otel.SetTracerProvider(tracenoop.NewTracerProvider())
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tr, err := autoexport.NewSpanExporter(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithResource(resource),
|
||||
sdktrace.WithBatcher(tr),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
),
|
||||
)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer tpCancel()
|
||||
shutdownErr := tp.Shutdown(tpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add tracing to the HTTP client
|
||||
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
|
||||
func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
|
||||
if !metrics {
|
||||
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mr, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
|
||||
}
|
||||
|
||||
mp := metric.NewMeterProvider(
|
||||
metric.WithResource(resource),
|
||||
metric.WithReader(mr),
|
||||
)
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer mpCancel()
|
||||
shutdownErr := mp.Shutdown(mpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
)
|
||||
|
||||
func defaultResource() (*resource.Resource, error) {
|
||||
return resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewSchemaless(
|
||||
semconv.ServiceName("pocket-id-backend"),
|
||||
semconv.ServiceVersion(common.Version),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func initOtel(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
|
||||
resource, err := defaultResource()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
|
||||
}
|
||||
|
||||
shutdownFns = make([]utils.Service, 0, 2)
|
||||
|
||||
httpClient = &http.Client{}
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Indicates a development-time error
|
||||
panic("Default transport is not of type *http.Transport")
|
||||
}
|
||||
httpClient.Transport = defaultTransport.Clone()
|
||||
|
||||
if traces {
|
||||
tr, err := autoexport.NewSpanExporter(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithResource(resource),
|
||||
sdktrace.WithBatcher(tr),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
),
|
||||
)
|
||||
|
||||
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer tpCancel()
|
||||
shutdownErr := tp.Shutdown(tpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
|
||||
} else {
|
||||
otel.SetTracerProvider(tracenoop.NewTracerProvider())
|
||||
}
|
||||
|
||||
if metrics {
|
||||
mr, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
|
||||
}
|
||||
mp := metric.NewMeterProvider(
|
||||
metric.WithResource(resource),
|
||||
metric.WithReader(mr),
|
||||
)
|
||||
|
||||
otel.SetMeterProvider(mp)
|
||||
shutdownFns = append(shutdownFns, func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer mpCancel()
|
||||
shutdownErr := mp.Shutdown(mpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
} else {
|
||||
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||
}
|
||||
|
||||
return shutdownFns, httpClient, nil
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
@@ -12,13 +12,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
@@ -32,7 +32,8 @@ var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *
|
||||
func initRouter(db *gorm.DB, svc *services) utils.Service {
|
||||
runner, err := initRouterInternal(db, svc)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init router: %v", err)
|
||||
slog.Error("Failed to init router", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
@@ -48,44 +49,27 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
// do not log these URLs
|
||||
loggerSkipPathsPrefix := []string{
|
||||
"GET /application-configuration/logo",
|
||||
"GET /application-configuration/background-image",
|
||||
"GET /application-configuration/favicon",
|
||||
"GET /_app",
|
||||
"GET /fonts",
|
||||
"GET /healthz",
|
||||
"HEAD /healthz",
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.LoggerWithConfig(gin.LoggerConfig{Skip: func(c *gin.Context) bool {
|
||||
for _, prefix := range loggerSkipPathsPrefix {
|
||||
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}}))
|
||||
initLogger(r)
|
||||
|
||||
if !common.EnvConfig.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
if common.EnvConfig.TracingEnabled {
|
||||
r.Use(otelgin.Middleware("pocket-id-backend"))
|
||||
r.Use(otelgin.Middleware(common.Name))
|
||||
}
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
|
||||
|
||||
// Setup global middleware
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewCspMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
|
||||
err := frontend.RegisterFrontend(r)
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
log.Println("Frontend is not included in the build. Skipping frontend registration.")
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||
}
|
||||
@@ -104,6 +88,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, svc.versionService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if common.EnvConfig.AppEnv != "production" {
|
||||
@@ -135,7 +120,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
addr = common.EnvConfig.UnixSocket
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, addr)
|
||||
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||
}
|
||||
@@ -154,7 +139,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
|
||||
// Service runner function
|
||||
runFn := func(ctx context.Context) error {
|
||||
log.Printf("Server listening on %s", addr)
|
||||
slog.Info("Server listening", slog.String("addr", addr))
|
||||
|
||||
// Start the server in a background goroutine
|
||||
go func() {
|
||||
@@ -163,7 +148,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
// Next call blocks until the server is shut down
|
||||
srvErr := srv.Serve(listener)
|
||||
if srvErr != http.ErrServerClosed {
|
||||
log.Fatalf("Error starting app server: %v", srvErr)
|
||||
slog.Error("Error starting app server", "error", srvErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -171,7 +157,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
err = systemd.SdNotifyReady()
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
|
||||
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||
}
|
||||
|
||||
// Block until the context is canceled
|
||||
@@ -184,7 +170,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
shutdownCancel()
|
||||
if shutdownErr != nil {
|
||||
// Log the error only (could be context canceled)
|
||||
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
|
||||
slog.Warn("App server shutdown error", "error", shutdownErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -192,3 +178,29 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
|
||||
return runFn, nil
|
||||
}
|
||||
|
||||
func initLogger(r *gin.Engine) {
|
||||
loggerSkipPathsPrefix := []string{
|
||||
"GET /api/application-configuration/logo",
|
||||
"GET /api/application-configuration/background-image",
|
||||
"GET /api/application-configuration/favicon",
|
||||
"GET /_app",
|
||||
"GET /fonts",
|
||||
"GET /healthz",
|
||||
"HEAD /healthz",
|
||||
}
|
||||
|
||||
r.Use(sloggin.SetLogger(
|
||||
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
|
||||
return slog.Default()
|
||||
}),
|
||||
sloggin.WithSkipper(func(c *gin.Context) bool {
|
||||
for _, prefix := range loggerSkipPathsPrefix {
|
||||
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
@@ -23,13 +23,17 @@ type services struct {
|
||||
userGroupService *service.UserGroupService
|
||||
ldapService *service.LdapService
|
||||
apiKeyService *service.ApiKeyService
|
||||
versionService *service.VersionService
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
|
||||
svc.appConfigService = service.NewAppConfigService(ctx, db)
|
||||
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||
}
|
||||
|
||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||
if err != nil {
|
||||
@@ -38,19 +42,28 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
|
||||
|
||||
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
||||
svc.jwtService = service.NewJwtService(db, svc.appConfigService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||
svc.customClaimService = service.NewCustomClaimService(db)
|
||||
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||
}
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
|
||||
svc.customClaimService = service.NewCustomClaimService(db)
|
||||
svc.webauthnService, err = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create WebAuthn service: %w", err)
|
||||
}
|
||||
|
||||
svc.oidcService, err = service.NewOidcService(ctx, db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService, svc.webauthnService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create OIDC service: %w", err)
|
||||
}
|
||||
|
||||
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||
|
||||
svc.versionService = service.NewVersionService(httpClient)
|
||||
|
||||
return svc, nil
|
||||
}
|
||||
|
||||
@@ -30,7 +30,10 @@ func init() {
|
||||
Use: "key-rotate",
|
||||
Short: "Generates a new token signing key and replaces the current one",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
db := bootstrap.NewDatabase()
|
||||
db, err := bootstrap.NewDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
|
||||
},
|
||||
@@ -80,7 +83,10 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
|
||||
}
|
||||
|
||||
// Init the services we need
|
||||
appConfigService := service.NewAppConfigService(ctx, db)
|
||||
appConfigService, err := service.NewAppConfigService(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create app config service: %w", err)
|
||||
}
|
||||
|
||||
// Get the key provider
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
|
||||
|
||||
@@ -97,7 +97,8 @@ func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bo
|
||||
db := testingutils.NewDatabaseForTest(t)
|
||||
|
||||
// Initialize app config service and create instance
|
||||
appConfigService := service.NewAppConfigService(t.Context(), db)
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||
|
||||
// Check if key exists before rotation
|
||||
@@ -140,14 +141,15 @@ func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantEr
|
||||
// Set up database storage config
|
||||
envConfig := &common.EnvConfigSchema{
|
||||
KeysStorage: "database",
|
||||
EncryptionKey: "test-encryption-key-characters-long",
|
||||
EncryptionKey: []byte("test-encryption-key-characters-long"),
|
||||
}
|
||||
|
||||
// Create test database
|
||||
db := testingutils.NewDatabaseForTest(t)
|
||||
|
||||
// Initialize app config service and create instance
|
||||
appConfigService := service.NewAppConfigService(t.Context(), db)
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||
|
||||
// Get key provider
|
||||
|
||||
@@ -24,11 +24,14 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
userArg := args[0]
|
||||
|
||||
// Connect to the database
|
||||
db := bootstrap.NewDatabase()
|
||||
db, err := bootstrap.NewDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the access token
|
||||
var oneTimeAccessToken *model.OneTimeAccessToken
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// Load the user to retrieve the user ID
|
||||
var user model.User
|
||||
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||
@@ -48,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
}
|
||||
|
||||
// Create a new access token that expires in 1 hour
|
||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
|
||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
|
||||
if txErr != nil {
|
||||
return fmt.Errorf("failed to generate access token: %w", txErr)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,14 @@ package common
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
)
|
||||
|
||||
@@ -23,32 +27,36 @@ const (
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||
defaultSqliteConnString string = "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate"
|
||||
defaultSqliteConnString string = "data/pocket-id.db"
|
||||
AppUrl string = "http://localhost:1411"
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
AppURL string `env:"APP_URL"`
|
||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING"`
|
||||
AppEnv string `env:"APP_ENV" options:"toLower"`
|
||||
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||
AppURL string `env:"APP_URL" options:"toLower"`
|
||||
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
KeysPath string `env:"KEYS_PATH"`
|
||||
KeysStorage string `env:"KEYS_STORAGE"`
|
||||
EncryptionKey string `env:"ENCRYPTION_KEY"`
|
||||
EncryptionKeyFile string `env:"ENCRYPTION_KEY_FILE"`
|
||||
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||
Port string `env:"PORT"`
|
||||
Host string `env:"HOST"`
|
||||
Host string `env:"HOST" options:"toLower"`
|
||||
UnixSocket string `env:"UNIX_SOCKET"`
|
||||
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
TrustProxy bool `env:"TRUST_PROXY"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
}
|
||||
|
||||
var EnvConfig = defaultConfig()
|
||||
@@ -56,20 +64,22 @@ var EnvConfig = defaultConfig()
|
||||
func init() {
|
||||
err := parseEnvConfig()
|
||||
if err != nil {
|
||||
log.Fatalf("Configuration error: %v", err)
|
||||
slog.Error("Configuration error", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() EnvConfigSchema {
|
||||
return EnvConfigSchema{
|
||||
AppEnv: "production",
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
DbConnectionString: "",
|
||||
UploadPath: "data/uploads",
|
||||
KeysPath: "data/keys",
|
||||
KeysStorage: "", // "database" or "file"
|
||||
EncryptionKey: "",
|
||||
AppURL: "http://localhost:1411",
|
||||
EncryptionKey: nil,
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
UnixSocket: "",
|
||||
@@ -83,30 +93,59 @@ func defaultConfig() EnvConfigSchema {
|
||||
TracingEnabled: false,
|
||||
TrustProxy: false,
|
||||
AnalyticsDisabled: false,
|
||||
AllowDowngrade: false,
|
||||
InternalAppURL: "",
|
||||
}
|
||||
}
|
||||
|
||||
func parseEnvConfig() error {
|
||||
err := env.ParseWithOptions(&EnvConfig, env.Options{})
|
||||
parsers := map[reflect.Type]env.ParserFunc{
|
||||
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
|
||||
return []byte(value), nil
|
||||
},
|
||||
}
|
||||
|
||||
err := env.ParseWithOptions(&EnvConfig, env.Options{
|
||||
FuncMap: parsers,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error parsing env config: %w", err)
|
||||
}
|
||||
|
||||
// Validate the environment variables
|
||||
switch EnvConfig.DbProvider {
|
||||
err = prepareEnvConfig(&EnvConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error preparing env config: %w", err)
|
||||
}
|
||||
|
||||
err = validateEnvConfig(&EnvConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// validateEnvConfig checks the EnvConfig for required fields and valid values
|
||||
func validateEnvConfig(config *EnvConfigSchema) error {
|
||||
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
|
||||
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||
}
|
||||
|
||||
switch config.DbProvider {
|
||||
case DbProviderSqlite:
|
||||
if EnvConfig.DbConnectionString == "" {
|
||||
EnvConfig.DbConnectionString = defaultSqliteConnString
|
||||
if config.DbConnectionString == "" {
|
||||
config.DbConnectionString = defaultSqliteConnString
|
||||
}
|
||||
case DbProviderPostgres:
|
||||
if EnvConfig.DbConnectionString == "" {
|
||||
if config.DbConnectionString == "" {
|
||||
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||
}
|
||||
|
||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||
parsedAppUrl, err := url.Parse(config.AppURL)
|
||||
if err != nil {
|
||||
return errors.New("APP_URL is not a valid URL")
|
||||
}
|
||||
@@ -114,19 +153,104 @@ func parseEnvConfig() error {
|
||||
return errors.New("APP_URL must not contain a path")
|
||||
}
|
||||
|
||||
switch EnvConfig.KeysStorage {
|
||||
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
||||
if config.InternalAppURL == "" {
|
||||
config.InternalAppURL = config.AppURL
|
||||
} else {
|
||||
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
|
||||
if err != nil {
|
||||
return errors.New("INTERNAL_APP_URL is not a valid URL")
|
||||
}
|
||||
if parsedInternalAppUrl.Path != "" {
|
||||
return errors.New("INTERNAL_APP_URL must not contain a path")
|
||||
}
|
||||
}
|
||||
|
||||
switch config.KeysStorage {
|
||||
// KeysStorage defaults to "file" if empty
|
||||
case "":
|
||||
EnvConfig.KeysStorage = "file"
|
||||
config.KeysStorage = "file"
|
||||
case "database":
|
||||
// If KeysStorage is "database", a key must be specified
|
||||
if EnvConfig.EncryptionKey == "" && EnvConfig.EncryptionKeyFile == "" {
|
||||
return errors.New("ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty when KEYS_STORAGE is database")
|
||||
if config.EncryptionKey == nil {
|
||||
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||
}
|
||||
case "file":
|
||||
// All good, these are valid values
|
||||
default:
|
||||
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage)
|
||||
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// prepareEnvConfig processes special options for EnvConfig fields
|
||||
func prepareEnvConfig(config *EnvConfigSchema) error {
|
||||
val := reflect.ValueOf(config).Elem()
|
||||
typ := val.Type()
|
||||
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Field(i)
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
optionsTag := fieldType.Tag.Get("options")
|
||||
options := strings.Split(optionsTag, ",")
|
||||
|
||||
for _, option := range options {
|
||||
switch option {
|
||||
case "toLower":
|
||||
if field.Kind() == reflect.String {
|
||||
field.SetString(strings.ToLower(field.String()))
|
||||
}
|
||||
case "file":
|
||||
err := resolveFileBasedEnvVariable(field, fieldType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
|
||||
// reads the content of the file specified by that variable, and sets the corresponding field's value.
|
||||
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
|
||||
// Only process string and []byte fields
|
||||
isString := field.Kind() == reflect.String
|
||||
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
|
||||
if !isString && !isByteSlice {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only process fields with the "env" tag
|
||||
envTag := fieldType.Tag.Get("env")
|
||||
if envTag == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
envVarName := envTag
|
||||
if commaIndex := len(envTag); commaIndex > 0 {
|
||||
envVarName = envTag[:commaIndex]
|
||||
}
|
||||
|
||||
// If the file environment variable is not set, skip
|
||||
envVarFileName := envVarName + "_FILE"
|
||||
envVarFileValue := os.Getenv(envVarFileName)
|
||||
if envVarFileValue == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
fileContent, err := os.ReadFile(envVarFileValue)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
|
||||
}
|
||||
|
||||
if isString {
|
||||
field.SetString(strings.TrimSpace(string(fileContent)))
|
||||
} else {
|
||||
field.SetBytes(fileContent)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -16,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
|
||||
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
|
||||
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
|
||||
})
|
||||
|
||||
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "postgres")
|
||||
t.Setenv("DB_PROVIDER", "POSTGRES")
|
||||
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("APP_URL", "https://example.com")
|
||||
|
||||
@@ -50,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
@@ -90,6 +91,28 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
assert.ErrorContains(t, err, "APP_URL must not contain a path")
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
|
||||
})
|
||||
|
||||
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
|
||||
})
|
||||
|
||||
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
@@ -110,7 +133,7 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "ENCRYPTION_KEY or ENCRYPTION_KEY_FILE must be non-empty")
|
||||
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||
})
|
||||
|
||||
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
|
||||
@@ -169,20 +192,67 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
t.Setenv("DB_PROVIDER", "postgres")
|
||||
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
||||
t.Setenv("APP_URL", "https://prod.example.com")
|
||||
t.Setenv("APP_ENV", "staging")
|
||||
t.Setenv("APP_ENV", "STAGING")
|
||||
t.Setenv("UPLOAD_PATH", "/custom/uploads")
|
||||
t.Setenv("KEYS_PATH", "/custom/keys")
|
||||
t.Setenv("PORT", "8080")
|
||||
t.Setenv("HOST", "127.0.0.1")
|
||||
t.Setenv("HOST", "LOCALHOST")
|
||||
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
|
||||
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
|
||||
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "staging", EnvConfig.AppEnv)
|
||||
assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
|
||||
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
|
||||
assert.Equal(t, "8080", EnvConfig.Port)
|
||||
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
|
||||
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
// Create temporary directory for test files
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Create test files
|
||||
encryptionKeyFile := tempDir + "/encryption_key.txt"
|
||||
encryptionKeyContent := "test-encryption-key-123"
|
||||
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
dbConnFile := tempDir + "/db_connection.txt"
|
||||
dbConnContent := "postgres://user:pass@localhost/testdb"
|
||||
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
binaryKeyFile := tempDir + "/binary_key.bin"
|
||||
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
|
||||
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("should process toLower and file options", func(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
config.AppEnv = "STAGING"
|
||||
config.Host = "LOCALHOST"
|
||||
|
||||
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
||||
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
||||
|
||||
err := prepareEnvConfig(&config)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, "staging", config.AppEnv)
|
||||
assert.Equal(t, "localhost", config.Host)
|
||||
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
||||
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
||||
})
|
||||
|
||||
t.Run("should handle binary data correctly", func(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
|
||||
|
||||
err := prepareEnvConfig(&config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -350,6 +350,15 @@ func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type ReauthenticationRequiredError struct{}
|
||||
|
||||
func (e *ReauthenticationRequiredError) Error() string {
|
||||
return "reauthentication required"
|
||||
}
|
||||
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
||||
type OpenSignupDisabledError struct{}
|
||||
|
||||
func (e *OpenSignupDisabledError) Error() string {
|
||||
@@ -359,3 +368,13 @@ func (e *OpenSignupDisabledError) Error() string {
|
||||
func (e *OpenSignupDisabledError) HttpStatusCode() int {
|
||||
return http.StatusForbidden
|
||||
}
|
||||
|
||||
type ClientIdAlreadyExistsError struct{}
|
||||
|
||||
func (e *ClientIdAlreadyExistsError) Error() string {
|
||||
return "Client ID already in use"
|
||||
}
|
||||
|
||||
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package common
|
||||
|
||||
// Name is the name of the application
|
||||
const Name = "pocket-id"
|
||||
|
||||
// Version contains the Pocket ID version.
|
||||
//
|
||||
// It can be set at build time using -ldflags.
|
||||
|
||||
@@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
|
||||
var input dto.ApiKeyCreateDto
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -109,7 +109,7 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
// @Router /api/application-configuration [put]
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -93,7 +93,7 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
@@ -55,8 +55,13 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
||||
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
||||
|
||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
||||
group.GET("/oidc/users/:id/clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
||||
group.GET("/oidc/users/me/authorized-clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAuthorizedClientsHandler)
|
||||
group.GET("/oidc/users/:id/authorized-clients", authMiddleware.Add(), oc.listAuthorizedClientsHandler)
|
||||
|
||||
group.DELETE("/oidc/users/me/authorized-clients/:clientId", authMiddleware.WithAdminNotRequired().Add(), oc.revokeOwnClientAuthorizationHandler)
|
||||
|
||||
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
|
||||
|
||||
}
|
||||
|
||||
type OidcController struct {
|
||||
@@ -257,7 +262,7 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
||||
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
||||
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
|
||||
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
|
||||
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||
return
|
||||
}
|
||||
@@ -487,11 +492,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Param client body dto.OidcClientUpdateDto true "Client information"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||
// @Router /api/oidc/clients/{id} [put]
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
var input dto.OidcClientUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -657,7 +662,7 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/me/clients [get]
|
||||
// @Router /api/oidc/users/me/authorized-clients [get]
|
||||
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
oc.listAuthorizedClients(c, userID)
|
||||
@@ -673,7 +678,7 @@ func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||
// @Router /api/oidc/users/{id}/clients [get]
|
||||
// @Router /api/oidc/users/{id}/authorized-clients [get]
|
||||
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
oc.listAuthorizedClients(c, userID)
|
||||
@@ -704,6 +709,58 @@ func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||
})
|
||||
}
|
||||
|
||||
// revokeOwnClientAuthorizationHandler godoc
|
||||
// @Summary Revoke authorization for an OIDC client
|
||||
// @Description Revoke the authorization for a specific OIDC client for the current user
|
||||
// @Tags OIDC
|
||||
// @Param clientId path string true "Client ID to revoke authorization for"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
|
||||
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||
clientID := c.Param("clientId")
|
||||
|
||||
userID := c.GetString("userID")
|
||||
|
||||
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// listOwnAccessibleClientsHandler godoc
|
||||
// @Summary List accessible OIDC clients for current user
|
||||
// @Description Get a list of OIDC clients that the current user can access
|
||||
// @Tags OIDC
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||
// @Router /api/oidc/users/me/clients [get]
|
||||
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
|
||||
Data: clients,
|
||||
Pagination: pagination,
|
||||
})
|
||||
}
|
||||
|
||||
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
@@ -771,7 +828,7 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
|
||||
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
defaultSignupTokenDuration = time.Hour
|
||||
)
|
||||
|
||||
// NewUserController creates a new controller for user management endpoints
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
@@ -193,7 +198,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
// @Router /api/users [post]
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -331,10 +336,17 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
|
||||
return
|
||||
}
|
||||
|
||||
var ttl time.Duration
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
} else {
|
||||
ttl = input.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
}
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -378,7 +390,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
// @Router /api/one-time-access-email [post]
|
||||
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
||||
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -411,7 +423,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
|
||||
userID := c.Param("id")
|
||||
|
||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
|
||||
ttl := input.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -457,7 +473,7 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
// @Router /api/signup/setup [post]
|
||||
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
|
||||
var input dto.SignUpDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -526,14 +542,20 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), input.ExpiresAt, input.UsageLimit)
|
||||
ttl := input.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultSignupTokenDuration
|
||||
}
|
||||
|
||||
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var tokenDto dto.SignupTokenDto
|
||||
if err := dto.MapStruct(signupToken, &tokenDto); err != nil {
|
||||
err = dto.MapStruct(signupToken, &tokenDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -606,7 +628,7 @@ func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
|
||||
// @Router /api/signup [post]
|
||||
func (uc *UserController) signupHandler(c *gin.Context) {
|
||||
var input dto.SignUpDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -635,7 +657,7 @@ func (uc *UserController) signupHandler(c *gin.Context) {
|
||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
// @Router /api/user-groups [post]
|
||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -152,7 +152,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
// @Router /api/user-groups/{id} [put]
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
40
backend/internal/controller/version_controller.go
Normal file
40
backend/internal/controller/version_controller.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
// NewVersionController registers version-related routes.
|
||||
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
|
||||
vc := &VersionController{versionService: versionService}
|
||||
group.GET("/version/latest", vc.getLatestVersionHandler)
|
||||
}
|
||||
|
||||
type VersionController struct {
|
||||
versionService *service.VersionService
|
||||
}
|
||||
|
||||
// getLatestVersionHandler godoc
|
||||
// @Summary Get latest available version of Pocket ID
|
||||
// @Tags Version
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Latest version information"
|
||||
// @Router /api/version/latest [get]
|
||||
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"latestVersion": tag,
|
||||
})
|
||||
}
|
||||
@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
|
||||
|
||||
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
||||
|
||||
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
|
||||
|
||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
||||
cookie.AddAccessTokenCookie(c, 0, "")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
_ = c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
var token string
|
||||
|
||||
// Try to create a reauthentication token with WebAuthn
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err == nil {
|
||||
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// If WebAuthn fails, try to create a reauthentication token with the access token
|
||||
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
|
||||
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ package controller
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
|
||||
var err error
|
||||
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
|
||||
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||
@@ -64,6 +67,9 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
||||
|
||||
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
appUrl := common.EnvConfig.AppURL
|
||||
|
||||
internalAppUrl := common.EnvConfig.InternalAppURL
|
||||
|
||||
alg, err := wkc.jwtService.GetKeyAlg()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
||||
@@ -71,19 +77,20 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
config := map[string]any{
|
||||
"issuer": appUrl,
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": appUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||
"token_endpoint": internalAppUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
||||
"introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
|
||||
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
|
||||
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
|
||||
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||
"authorization_response_iss_parameter_supported": true,
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
)
|
||||
|
||||
type ApiKeyCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=50"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" unorm:"nfc"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type ApiKeyDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Description *string `json:"description"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
|
||||
@@ -12,12 +12,14 @@ type AppConfigVariableDto struct {
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
@@ -39,6 +41,7 @@ type AppConfigUpdateDto struct {
|
||||
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
|
||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
||||
}
|
||||
|
||||
type CustomClaimCreateDto struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||
Value string `json:"value" binding:"required" unorm:"nfc"`
|
||||
}
|
||||
|
||||
94
backend/internal/dto/dto_normalize.go
Normal file
94
backend/internal/dto/dto_normalize.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"reflect"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
|
||||
func Normalize(obj any) {
|
||||
v := reflect.ValueOf(obj)
|
||||
if v.Kind() != reflect.Ptr || v.IsNil() {
|
||||
return
|
||||
}
|
||||
v = v.Elem()
|
||||
|
||||
// Handle case where obj is a slice of models
|
||||
if v.Kind() == reflect.Slice {
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
elem := v.Index(i)
|
||||
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
|
||||
Normalize(elem.Interface())
|
||||
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
|
||||
Normalize(elem.Addr().Interface())
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if v.Kind() != reflect.Struct {
|
||||
return
|
||||
}
|
||||
|
||||
// Iterate through all fields looking for those with the "unorm" tag
|
||||
t := v.Type()
|
||||
loop:
|
||||
for i := range t.NumField() {
|
||||
field := t.Field(i)
|
||||
|
||||
unormTag := field.Tag.Get("unorm")
|
||||
if unormTag == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fv := v.Field(i)
|
||||
if !fv.CanSet() || fv.Kind() != reflect.String {
|
||||
continue
|
||||
}
|
||||
|
||||
var form norm.Form
|
||||
switch unormTag {
|
||||
case "nfc":
|
||||
form = norm.NFC
|
||||
case "nfkc":
|
||||
form = norm.NFKC
|
||||
case "nfd":
|
||||
form = norm.NFD
|
||||
case "nfkd":
|
||||
form = norm.NFKD
|
||||
default:
|
||||
continue loop
|
||||
}
|
||||
|
||||
val := fv.String()
|
||||
val = form.String(val)
|
||||
fv.SetString(val)
|
||||
}
|
||||
}
|
||||
|
||||
func ShouldBindWithNormalizedJSON(ctx *gin.Context, obj any) error {
|
||||
return ctx.ShouldBindWith(obj, binding.JSON)
|
||||
}
|
||||
|
||||
type NormalizerJSONBinding struct{}
|
||||
|
||||
func (NormalizerJSONBinding) Name() string {
|
||||
return "json"
|
||||
}
|
||||
|
||||
func (NormalizerJSONBinding) Bind(req *http.Request, obj any) error {
|
||||
// Use the default JSON binder
|
||||
err := binding.JSON.Bind(req, obj)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Perform normalization
|
||||
Normalize(obj)
|
||||
|
||||
return nil
|
||||
}
|
||||
84
backend/internal/dto/dto_normalize_test.go
Normal file
84
backend/internal/dto/dto_normalize_test.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
type testDto struct {
|
||||
Name string `unorm:"nfc"`
|
||||
Description string `unorm:"nfd"`
|
||||
Other string
|
||||
BadForm string `unorm:"bad"`
|
||||
}
|
||||
|
||||
func TestNormalize(t *testing.T) {
|
||||
input := testDto{
|
||||
// Is in NFC form already
|
||||
Name: norm.NFC.String("Café"),
|
||||
// NFC form will be normalized to NFD
|
||||
Description: norm.NFC.String("vërø"),
|
||||
// Should be unchanged
|
||||
Other: "NöTag",
|
||||
// Should be unchanged
|
||||
BadForm: "BåD",
|
||||
}
|
||||
|
||||
Normalize(&input)
|
||||
|
||||
assert.Equal(t, norm.NFC.String("Café"), input.Name)
|
||||
assert.Equal(t, norm.NFD.String("vërø"), input.Description)
|
||||
assert.Equal(t, "NöTag", input.Other)
|
||||
assert.Equal(t, "BåD", input.BadForm)
|
||||
}
|
||||
|
||||
func TestNormalizeSlice(t *testing.T) {
|
||||
obj1 := testDto{
|
||||
Name: norm.NFC.String("Café1"),
|
||||
Description: norm.NFC.String("vërø1"),
|
||||
Other: "NöTag1",
|
||||
BadForm: "BåD1",
|
||||
}
|
||||
obj2 := testDto{
|
||||
Name: norm.NFD.String("Résumé2"),
|
||||
Description: norm.NFD.String("accéléré2"),
|
||||
Other: "NöTag2",
|
||||
BadForm: "BåD2",
|
||||
}
|
||||
|
||||
t.Run("slice of structs", func(t *testing.T) {
|
||||
slice := []testDto{obj1, obj2}
|
||||
Normalize(&slice)
|
||||
|
||||
// Verify first element
|
||||
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
|
||||
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
|
||||
assert.Equal(t, "NöTag1", slice[0].Other)
|
||||
assert.Equal(t, "BåD1", slice[0].BadForm)
|
||||
|
||||
// Verify second element
|
||||
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
|
||||
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
|
||||
assert.Equal(t, "NöTag2", slice[1].Other)
|
||||
assert.Equal(t, "BåD2", slice[1].BadForm)
|
||||
})
|
||||
|
||||
t.Run("slice of pointers to structs", func(t *testing.T) {
|
||||
slice := []*testDto{&obj1, &obj2}
|
||||
Normalize(&slice)
|
||||
|
||||
// Verify first element
|
||||
assert.Equal(t, norm.NFC.String("Café1"), slice[0].Name)
|
||||
assert.Equal(t, norm.NFD.String("vërø1"), slice[0].Description)
|
||||
assert.Equal(t, "NöTag1", slice[0].Other)
|
||||
assert.Equal(t, "BåD1", slice[0].BadForm)
|
||||
|
||||
// Verify second element
|
||||
assert.Equal(t, norm.NFC.String("Résumé2"), slice[1].Name)
|
||||
assert.Equal(t, norm.NFD.String("accéléré2"), slice[1].Description)
|
||||
assert.Equal(t, "NöTag2", slice[1].Other)
|
||||
assert.Equal(t, "BåD2", slice[1].BadForm)
|
||||
})
|
||||
}
|
||||
@@ -1,9 +1,13 @@
|
||||
package dto
|
||||
|
||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
|
||||
type OidcClientMetaDataDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
LaunchURL *string `json:"launchURL"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
}
|
||||
|
||||
type OidcClientDto struct {
|
||||
@@ -25,13 +29,20 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
||||
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
|
||||
}
|
||||
|
||||
type OidcClientUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
OidcClientUpdateDto
|
||||
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
|
||||
}
|
||||
|
||||
type OidcClientCredentialsDto struct {
|
||||
@@ -46,12 +57,13 @@ type OidcClientFederatedIdentityDto struct {
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
@@ -75,6 +87,7 @@ type OidcCreateTokensDto struct {
|
||||
RefreshToken string `form:"refresh_token"`
|
||||
ClientAssertion string `form:"client_assertion"`
|
||||
ClientAssertionType string `form:"client_assertion_type"`
|
||||
Resource string `form:"resource"`
|
||||
}
|
||||
|
||||
type OidcIntrospectDto struct {
|
||||
@@ -145,8 +158,9 @@ type DeviceCodeInfoDto struct {
|
||||
}
|
||||
|
||||
type AuthorizedOidcClientDto struct {
|
||||
Scope string `json:"scope"`
|
||||
Client OidcClientMetaDataDto `json:"client"`
|
||||
Scope string `json:"scope"`
|
||||
Client OidcClientMetaDataDto `json:"client"`
|
||||
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
type OidcClientPreviewDto struct {
|
||||
@@ -154,3 +168,8 @@ type OidcClientPreviewDto struct {
|
||||
AccessToken map[string]any `json:"accessToken"`
|
||||
UserInfo map[string]any `json:"userInfo"`
|
||||
}
|
||||
|
||||
type AccessibleOidcClientDto struct {
|
||||
OidcClientMetaDataDto
|
||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type SignupTokenCreateDto struct {
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
type SignupTokenDto struct {
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
ID string `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Email string `json:"email" `
|
||||
FirstName string `json:"firstName"`
|
||||
LastName string `json:"lastName"`
|
||||
LastName *string `json:"lastName"`
|
||||
DisplayName string `json:"displayName"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
CustomClaims []CustomClaimDto `json:"customClaims"`
|
||||
@@ -17,28 +23,40 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"max=50"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
LdapID string `json:"-"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (u UserCreateDto) Validate() error {
|
||||
e, ok := binding.Validator.Engine().(interface {
|
||||
Struct(s any) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("validator does not implement the expected interface")
|
||||
}
|
||||
|
||||
return e.Struct(u)
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
RedirectPath string `json:"redirectPath"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsAdminDto struct {
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
@@ -46,9 +64,9 @@ type UserUpdateUserGroupDto struct {
|
||||
}
|
||||
|
||||
type SignUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"max=50"`
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
104
backend/internal/dto/user_dto_test.go
Normal file
104
backend/internal/dto/user_dto_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUserCreateDto_Validate(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input UserCreateDto
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid input",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "missing username",
|
||||
input: UserCreateDto{
|
||||
Email: "test@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
name: "missing display name",
|
||||
input: UserCreateDto{
|
||||
Email: "test@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
name: "username contains invalid characters",
|
||||
input: UserCreateDto{
|
||||
Username: "test/ser",
|
||||
Email: "test@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Username' failed on the 'username' tag",
|
||||
},
|
||||
{
|
||||
name: "invalid email",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "not-an-email",
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'Email' failed on the 'email' tag",
|
||||
},
|
||||
{
|
||||
name: "first name too short",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
FirstName: "",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
name: "last name too long",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: "test@example.com",
|
||||
FirstName: "John",
|
||||
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.input.Validate()
|
||||
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, tc.wantErr)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -34,11 +37,22 @@ type UserGroupDtoWithUserCount struct {
|
||||
}
|
||||
|
||||
type UserGroupCreateDto struct {
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
func (g UserGroupCreateDto) Validate() error {
|
||||
e, ok := binding.Validator.Engine().(interface {
|
||||
Struct(s any) error
|
||||
})
|
||||
if !ok {
|
||||
return errors.New("validator does not implement the expected interface")
|
||||
}
|
||||
|
||||
return e.Struct(g)
|
||||
}
|
||||
|
||||
type UserGroupUpdateUsersDto struct {
|
||||
UserIDs []string `json:"userIds" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
@@ -13,14 +17,71 @@ import (
|
||||
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
|
||||
|
||||
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
return validateUsernameRegex.MatchString(fl.Field().String())
|
||||
}
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
func init() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
v := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
// Maximum allowed value for TTLs
|
||||
const maxTTL = 31 * 24 * time.Hour
|
||||
|
||||
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||
return ValidateUsername(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for username: " + err.Error())
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for client_id: " + err.Error())
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Allow zero, which means the field wasn't set
|
||||
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for ttl: " + err.Error())
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURL(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for callback_url: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateUsername validates username inputs
|
||||
func ValidateUsername(username string) bool {
|
||||
return validateUsernameRegex.MatchString(username)
|
||||
}
|
||||
|
||||
// ValidateClientID validates client ID inputs
|
||||
func ValidateClientID(clientID string) bool {
|
||||
return validateClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// ValidateCallbackURL validates callback URLs with support for wildcards
|
||||
func ValidateCallbackURL(raw string) bool {
|
||||
if raw == "*" {
|
||||
return true
|
||||
}
|
||||
|
||||
// Replace all '*' with 'x' to check if the rest is still a valid URI
|
||||
test := strings.ReplaceAll(raw, "*", "x")
|
||||
|
||||
u, err := url.Parse(test)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !u.IsAbs() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
58
backend/internal/dto/validations_test.go
Normal file
58
backend/internal/dto/validations_test.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateUsername(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid simple", "user123", true},
|
||||
{"valid with dot", "user.name", true},
|
||||
{"valid with underscore", "user_name", true},
|
||||
{"valid with hyphen", "user-name", true},
|
||||
{"valid with at", "user@name", true},
|
||||
{"starts with symbol", ".username", false},
|
||||
{"ends with non-alphanumeric", "username-", false},
|
||||
{"contains space", "user name", false},
|
||||
{"empty", "", false},
|
||||
{"only special chars", "-._@", false},
|
||||
{"valid long", "a1234567890_b.c-d@e", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateUsername(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateClientID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid simple", "client123", true},
|
||||
{"valid with dot", "client.id", true},
|
||||
{"valid with underscore", "client_id", true},
|
||||
{"valid with hyphen", "client-id", true},
|
||||
{"valid with all", "client.id-123_abc", true},
|
||||
{"contains space", "client id", false},
|
||||
{"contains at", "client@id", false},
|
||||
{"empty", "", false},
|
||||
{"only special chars", "-._", true},
|
||||
{"invalid char", "client!id", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateClientID(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=30"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50"`
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) erro
|
||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
)
|
||||
}
|
||||
@@ -104,6 +105,20 @@ func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
|
||||
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAuditLogs deletes audit logs older than 90 days
|
||||
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||
st := j.db.
|
||||
|
||||
53
backend/internal/middleware/csp_middleware.go
Normal file
53
backend/internal/middleware/csp_middleware.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CspMiddleware sets a Content Security Policy header and, when possible,
|
||||
// includes a per-request nonce for inline scripts.
|
||||
type CspMiddleware struct{}
|
||||
|
||||
func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} }
|
||||
|
||||
// GetCSPNonce returns the CSP nonce generated for this request, if any.
|
||||
func GetCSPNonce(c *gin.Context) string {
|
||||
if v, ok := c.Get("csp_nonce"); ok {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (m *CspMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// Generate a random base64 nonce for this request
|
||||
nonce := generateNonce()
|
||||
c.Set("csp_nonce", nonce)
|
||||
|
||||
csp := "default-src 'self'; " +
|
||||
"base-uri 'self'; " +
|
||||
"object-src 'none'; " +
|
||||
"frame-ancestors 'none'; " +
|
||||
"form-action 'self'; " +
|
||||
"img-src 'self' data: blob:; " +
|
||||
"font-src 'self'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"script-src 'self' 'nonce-" + nonce + "'"
|
||||
|
||||
c.Writer.Header().Set("Content-Security-Policy", csp)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func generateNonce() string {
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "" // if generation fails, return empty; policy will omit nonce
|
||||
}
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
|
||||
case "email":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
|
||||
case "username":
|
||||
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
|
||||
case "url":
|
||||
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
|
||||
case "min":
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
|
||||
// Skip rate limiting for localhost and test environment
|
||||
// If the client ip is localhost the request comes from the frontend
|
||||
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
||||
if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -34,13 +34,15 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
|
||||
|
||||
type AppConfig struct {
|
||||
// General
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||
// Internal
|
||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||
@@ -72,6 +74,7 @@ type AppConfig struct {
|
||||
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
|
||||
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
|
||||
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
|
||||
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
|
||||
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
|
||||
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
|
||||
@@ -178,7 +181,7 @@ type AppConfigKeyNotFoundError struct {
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return fmt.Sprintf("cannot find config key '%s'", e.field)
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
@@ -192,7 +195,7 @@ type AppConfigInternalForbiddenError struct {
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Error() string {
|
||||
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
|
||||
return "field '" + e.field + "' is internal and can't be updated"
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Is(target error) bool {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -11,7 +12,9 @@ import (
|
||||
)
|
||||
|
||||
type UserAuthorizedOidcClient struct {
|
||||
Scope string
|
||||
Scope string
|
||||
LastUsedAt datatype.DateTime `sortable:"true"`
|
||||
|
||||
UserID string `gorm:"primary_key;"`
|
||||
User User
|
||||
|
||||
@@ -19,6 +22,14 @@ type UserAuthorizedOidcClient struct {
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
func (c UserAuthorizedOidcClient) Scopes() []string {
|
||||
if len(c.Scope) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return strings.Split(c.Scope, " ")
|
||||
}
|
||||
|
||||
type OidcAuthorizationCode struct {
|
||||
Base
|
||||
|
||||
@@ -38,19 +49,22 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
Credentials OidcClientCredentials
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
RequiresReauthentication bool
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID *string
|
||||
CreatedBy *User
|
||||
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
|
||||
}
|
||||
|
||||
type OidcRefreshToken struct {
|
||||
@@ -67,6 +81,14 @@ type OidcRefreshToken struct {
|
||||
Client OidcClient
|
||||
}
|
||||
|
||||
func (c OidcRefreshToken) Scopes() []string {
|
||||
if len(c.Scope) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
|
||||
return strings.Split(c.Scope, " ")
|
||||
}
|
||||
|
||||
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
// Compute HasLogo field
|
||||
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
||||
|
||||
@@ -13,14 +13,15 @@ import (
|
||||
type User struct {
|
||||
Base
|
||||
|
||||
Username string `sortable:"true"`
|
||||
Email string `sortable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true"`
|
||||
Locale *string
|
||||
LdapID *string
|
||||
Disabled bool `sortable:"true"`
|
||||
Username string `sortable:"true"`
|
||||
Email string `sortable:"true"`
|
||||
FirstName string `sortable:"true"`
|
||||
LastName string `sortable:"true"`
|
||||
DisplayName string `sortable:"true"`
|
||||
IsAdmin bool `sortable:"true"`
|
||||
Locale *string
|
||||
LdapID *string
|
||||
Disabled bool `sortable:"true"`
|
||||
|
||||
CustomClaims []CustomClaim
|
||||
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
|
||||
@@ -31,7 +32,12 @@ func (u User) WebAuthnID() []byte { return []byte(u.ID) }
|
||||
|
||||
func (u User) WebAuthnName() string { return u.Username }
|
||||
|
||||
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName }
|
||||
func (u User) WebAuthnDisplayName() string {
|
||||
if u.DisplayName != "" {
|
||||
return u.DisplayName
|
||||
}
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
func (u User) WebAuthnIcon() string { return "" }
|
||||
|
||||
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
return descriptors
|
||||
}
|
||||
|
||||
func (u User) FullName() string { return u.FirstName + " " + u.LastName }
|
||||
func (u User) FullName() string {
|
||||
return u.FirstName + " " + u.LastName
|
||||
}
|
||||
|
||||
func (u User) Initials() string {
|
||||
first := utils.GetFirstCharacter(u.FirstName)
|
||||
|
||||
@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type ReauthenticationToken struct {
|
||||
Base
|
||||
Token string
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
|
||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
|
||||
@@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
||||
apiKey := model.ApiKey{
|
||||
Name: input.Name,
|
||||
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
||||
Description: &input.Description,
|
||||
ExpiresAt: datatype.DateTime(input.ExpiresAt),
|
||||
Description: input.Description,
|
||||
ExpiresAt: input.ExpiresAt,
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
|
||||
@@ -4,17 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-uuid"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
@@ -29,22 +26,22 @@ type AppConfigService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAppConfigService(ctx context.Context, db *gorm.DB) *AppConfigService {
|
||||
func NewAppConfigService(ctx context.Context, db *gorm.DB) (*AppConfigService, error) {
|
||||
service := &AppConfigService{
|
||||
db: db,
|
||||
}
|
||||
|
||||
err := service.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize app config service: %v", err)
|
||||
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
|
||||
}
|
||||
|
||||
err = service.initInstanceID(ctx)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize instance ID: %v", err)
|
||||
return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
|
||||
}
|
||||
|
||||
return service
|
||||
return service, nil
|
||||
}
|
||||
|
||||
// GetDbConfig returns the application configuration.
|
||||
@@ -63,15 +60,17 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
// Values are the default ones
|
||||
return &model.AppConfig{
|
||||
// General
|
||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||
// Internal
|
||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
||||
BackgroundImageType: model.AppConfigVariable{Value: "webp"},
|
||||
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
||||
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
|
||||
InstanceID: model.AppConfigVariable{Value: ""},
|
||||
@@ -101,6 +100,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
LdapAttributeUserEmail: model.AppConfigVariable{},
|
||||
LdapAttributeUserFirstName: model.AppConfigVariable{},
|
||||
LdapAttributeUserLastName: model.AppConfigVariable{},
|
||||
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
|
||||
@@ -414,12 +414,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
|
||||
field := rt.Field(i)
|
||||
|
||||
// Get the key and internal tag values
|
||||
tagValue := strings.Split(field.Tag.Get("key"), ",")
|
||||
key := tagValue[0]
|
||||
isInternal := slices.Contains(tagValue, "internal")
|
||||
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||
|
||||
// Internal fields are loaded from the database as they can't be set from the environment
|
||||
if isInternal {
|
||||
if attrs == "internal" {
|
||||
var value string
|
||||
err := tx.WithContext(ctx).
|
||||
Model(&model.AppConfigVariable{}).
|
||||
@@ -438,6 +436,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
|
||||
value, ok := os.LookupEnv(envVarName)
|
||||
if ok {
|
||||
rv.Field(i).FieldByName("Value").SetString(value)
|
||||
continue
|
||||
}
|
||||
|
||||
// If it's sensitive, we also allow reading from file
|
||||
if attrs == "sensitive" {
|
||||
fileName := os.Getenv(envVarName + "_FILE")
|
||||
if fileName != "" {
|
||||
b, err := os.ReadFile(fileName)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read secret '%s' from file '%s': %w", envVarName, fileName, err)
|
||||
}
|
||||
rv.Field(i).FieldByName("Value").SetString(string(b))
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
userAgentParser "github.com/mileusna/useragent"
|
||||
@@ -11,6 +10,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
@@ -22,7 +22,12 @@ type AuditLogService struct {
|
||||
}
|
||||
|
||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
|
||||
return &AuditLogService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
emailService: emailService,
|
||||
geoliteService: geoliteService,
|
||||
}
|
||||
}
|
||||
|
||||
// Create creates a new audit log entry in the database
|
||||
@@ -70,14 +75,19 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
|
||||
// Count the number of times the user has logged in from the same device
|
||||
var count int64
|
||||
err := tx.
|
||||
stmt := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.AuditLog{}).
|
||||
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).
|
||||
Count(&count).
|
||||
Error
|
||||
Where("user_id = ? AND user_agent = ?", userID, userAgent)
|
||||
if ipAddress == "" {
|
||||
// An empty IP address is stored as NULL in the database
|
||||
stmt = stmt.Where("ip_address IS NULL")
|
||||
} else {
|
||||
stmt = stmt.Where("ip_address = ?", ipAddress)
|
||||
}
|
||||
err := stmt.Count(&count).Error
|
||||
if err != nil {
|
||||
log.Printf("Failed to count audit logs: %v", err)
|
||||
slog.ErrorContext(ctx, "Failed to count audit logs", slog.Any("error", err))
|
||||
return createdAuditLog
|
||||
}
|
||||
|
||||
@@ -86,7 +96,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
// We use a background context here as this is running in a goroutine
|
||||
//nolint:contextcheck
|
||||
go func() {
|
||||
innerCtx := context.Background()
|
||||
span := trace.SpanFromContext(ctx)
|
||||
innerCtx := trace.ContextWithSpan(context.Background(), span)
|
||||
|
||||
// Note we don't use the transaction here because this is running in background
|
||||
var user model.User
|
||||
@@ -96,7 +107,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
First(&user).
|
||||
Error
|
||||
if innerErr != nil {
|
||||
log.Printf("Failed to load user: %v", innerErr)
|
||||
slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
|
||||
return
|
||||
}
|
||||
|
||||
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
||||
@@ -110,7 +122,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
||||
DateTime: createdAuditLog.CreatedAt.UTC(),
|
||||
})
|
||||
if innerErr != nil {
|
||||
log.Printf("Failed to send email to '%s': %v", user.Email, innerErr)
|
||||
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ func isReservedClaim(key string) bool {
|
||||
"name",
|
||||
"email",
|
||||
"preferred_username",
|
||||
"display_name",
|
||||
"groups",
|
||||
TokenTypeClaim,
|
||||
"sub",
|
||||
@@ -55,16 +56,46 @@ const (
|
||||
|
||||
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
return s.updateCustomClaims(ctx, UserID, userID, claims)
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
return s.updateCustomClaims(ctx, UserGroupID, userGroupID, claims)
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
// updateCustomClaims updates the custom claims for a user or user group
|
||||
func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
|
||||
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||
// Check for duplicate keys in the claims slice
|
||||
seenKeys := make(map[string]struct{})
|
||||
for _, claim := range claims {
|
||||
@@ -74,11 +105,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
|
||||
seenKeys[claim.Key] = struct{}{}
|
||||
}
|
||||
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var existingClaims []model.CustomClaim
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
@@ -150,11 +176,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return updatedClaims, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
@@ -78,21 +78,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Base: model.Base{
|
||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
},
|
||||
Username: "tim",
|
||||
Email: "tim.cook@test.com",
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
IsAdmin: true,
|
||||
Username: "tim",
|
||||
Email: "tim.cook@test.com",
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
DisplayName: "Tim Cook",
|
||||
IsAdmin: true,
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
},
|
||||
Username: "craig",
|
||||
Email: "craig.federighi@test.com",
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
IsAdmin: false,
|
||||
Username: "craig",
|
||||
Email: "craig.federighi@test.com",
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
DisplayName: "Craig Federighi",
|
||||
IsAdmin: false,
|
||||
},
|
||||
}
|
||||
for _, user := range users {
|
||||
@@ -154,11 +156,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
LaunchURL: utils.Ptr("https://nextcloud.local"),
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: users[0].ID,
|
||||
CreatedByID: utils.Ptr(users[0].ID),
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -167,11 +170,21 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||
CreatedByID: users[1].ID,
|
||||
CreatedByID: utils.Ptr(users[1].ID),
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[1],
|
||||
},
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||
},
|
||||
Name: "Tailscale",
|
||||
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
|
||||
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
|
||||
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
|
||||
CreatedByID: utils.Ptr(users[0].ID),
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||
@@ -179,7 +192,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Name: "Federated",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
|
||||
CreatedByID: users[1].ID,
|
||||
CreatedByID: utils.Ptr(users[1].ID),
|
||||
AllowedUserGroups: []model.UserGroup{},
|
||||
Credentials: model.OidcClientCredentials{
|
||||
FederatedIdentities: []model.OidcClientFederatedIdentity{
|
||||
@@ -245,14 +258,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
|
||||
userAuthorizedClients := []model.UserAuthorizedOidcClient{
|
||||
{
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 1, 13, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
{
|
||||
Scope: "openid profile email",
|
||||
UserID: users[1].ID,
|
||||
ClientID: oidcClients[2].ID,
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[2].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 10, 14, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
{
|
||||
Scope: "openid profile email",
|
||||
UserID: users[1].ID,
|
||||
ClientID: oidcClients[3].ID,
|
||||
LastUsedAt: datatype.DateTime(time.Date(2025, 8, 12, 12, 0, 0, 0, time.UTC)),
|
||||
},
|
||||
}
|
||||
for _, userAuthorizedClient := range userAuthorizedClients {
|
||||
@@ -324,7 +345,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901",
|
||||
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||
},
|
||||
Token: "PARTIAL567890ABC",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
|
||||
@@ -333,7 +354,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012",
|
||||
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
|
||||
},
|
||||
Token: "EXPIRED34567890B",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
|
||||
@@ -342,7 +363,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
ID: "d4e5f6g7-h8i9-0123-def0-234567890123",
|
||||
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||
},
|
||||
Token: "FULLYUSED567890C",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
@@ -402,9 +423,9 @@ func (s *TestService) ResetDatabase() error {
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *TestService) ResetApplicationImages() error {
|
||||
func (s *TestService) ResetApplicationImages(ctx context.Context) error {
|
||||
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
||||
log.Printf("Error removing directory: %v", err)
|
||||
slog.ErrorContext(ctx, "Error removing directory", slog.Any("error", err))
|
||||
return err
|
||||
}
|
||||
|
||||
|
||||
@@ -262,7 +262,7 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
|
||||
|
||||
// prepare text part
|
||||
var textHeader = textproto.MIMEHeader{}
|
||||
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8")
|
||||
textHeader.Add("Content-Type", "text/plain; charset=UTF-8")
|
||||
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||
textPart, err := mpart.CreatePart(textHeader)
|
||||
if err != nil {
|
||||
@@ -274,18 +274,17 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("execute text template: %w", err)
|
||||
}
|
||||
textQp.Close()
|
||||
|
||||
// prepare html part
|
||||
var htmlHeader = textproto.MIMEHeader{}
|
||||
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
|
||||
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||
htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
|
||||
htmlHeader.Add("Content-Transfer-Encoding", "8bit")
|
||||
htmlPart, err := mpart.CreatePart(htmlHeader)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("create html part: %w", err)
|
||||
}
|
||||
|
||||
htmlQp := quotedprintable.NewWriter(htmlPart)
|
||||
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
|
||||
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlPart, "root", data)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("execute html template: %w", err)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
@@ -52,13 +52,14 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
|
||||
|
||||
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
|
||||
// Warn the user, and disable the periodic updater
|
||||
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
|
||||
slog.Warn("MAXMIND_LICENSE_KEY environment variable is empty: the GeoLite2 City database won't be updated")
|
||||
service.disableUpdater = true
|
||||
}
|
||||
|
||||
// Initialize IPv6 local ranges
|
||||
if err := service.initializeIPv6LocalRanges(); err != nil {
|
||||
log.Printf("Warning: Failed to initialize IPv6 local ranges: %v", err)
|
||||
err := service.initializeIPv6LocalRanges()
|
||||
if err != nil {
|
||||
slog.Warn("Failed to initialize IPv6 local ranges", slog.Any("error", err))
|
||||
}
|
||||
|
||||
return service
|
||||
@@ -96,7 +97,7 @@ func (s *GeoLiteService) initializeIPv6LocalRanges() error {
|
||||
s.localIPv6Ranges = localRanges
|
||||
|
||||
if len(localRanges) > 0 {
|
||||
log.Printf("Initialized %d IPv6 local ranges", len(localRanges))
|
||||
slog.Info("Initialized IPv6 local ranges", slog.Int("count", len(localRanges)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -186,11 +187,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
||||
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
||||
func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
||||
if s.isDatabaseUpToDate() {
|
||||
log.Println("GeoLite2 City database is up-to-date")
|
||||
slog.Info("GeoLite2 City database is up-to-date")
|
||||
return nil
|
||||
}
|
||||
|
||||
log.Println("Updating GeoLite2 City database")
|
||||
slog.Info("Updating GeoLite2 City database")
|
||||
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
|
||||
@@ -217,7 +218,7 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
||||
return fmt.Errorf("failed to extract database: %w", err)
|
||||
}
|
||||
|
||||
log.Println("GeoLite2 City database successfully updated.")
|
||||
slog.Info("GeoLite2 City database successfully updated.")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
@@ -64,16 +63,16 @@ type JwtService struct {
|
||||
jwksEncoded []byte
|
||||
}
|
||||
|
||||
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) *JwtService {
|
||||
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
|
||||
service := &JwtService{}
|
||||
|
||||
// Ensure keys are generated or loaded
|
||||
err := service.init(db, appConfigService, &common.EnvConfig)
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to initialize jwt service: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return service
|
||||
return service, nil
|
||||
}
|
||||
|
||||
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
|
||||
|
||||
@@ -8,20 +8,21 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LdapService struct {
|
||||
@@ -129,7 +130,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
||||
|
||||
// Skip groups without a valid LDAP ID
|
||||
if ldapId == "" {
|
||||
log.Printf("Skipping LDAP group without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
|
||||
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -167,17 +168,19 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
||||
|
||||
userResult, err := client.Search(userSearchReq)
|
||||
if err != nil || len(userResult.Entries) == 0 {
|
||||
log.Printf("Could not resolve group member DN '%s': %v", member, err)
|
||||
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||
if username == "" {
|
||||
log.Printf("Could not extract username from group member DN '%s'", member)
|
||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
username = norm.NFC.String(username)
|
||||
|
||||
var databaseUser model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
@@ -199,6 +202,13 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapId,
|
||||
}
|
||||
dto.Normalize(syncGroup)
|
||||
|
||||
err = syncGroup.Validate()
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "LDAP user group object is not valid", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
|
||||
@@ -248,7 +258,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
||||
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
||||
}
|
||||
|
||||
log.Printf("Deleted group '%s'", group.Name)
|
||||
slog.Info("Deleted group", slog.String("group", group.Name))
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -268,6 +278,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
dbConfig.LdapAttributeUserFirstName.Value,
|
||||
dbConfig.LdapAttributeUserLastName.Value,
|
||||
dbConfig.LdapAttributeUserProfilePicture.Value,
|
||||
dbConfig.LdapAttributeUserDisplayName.Value,
|
||||
}
|
||||
|
||||
// Filters must start and finish with ()!
|
||||
@@ -293,7 +304,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
|
||||
// Skip users without a valid LDAP ID
|
||||
if ldapId == "" {
|
||||
log.Printf("Skipping LDAP user without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeUserUniqueIdentifier.Value)
|
||||
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -309,7 +320,6 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
|
||||
// If a user is found (even if disabled), enable them since they're now back in LDAP
|
||||
if databaseUser.ID != "" && databaseUser.Disabled {
|
||||
// Use the transaction instead of the direct context
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
@@ -318,7 +328,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
log.Printf("Failed to enable user %s: %v", databaseUser.Username, err)
|
||||
return fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -337,18 +347,26 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
}
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
}
|
||||
dto.Normalize(newUser)
|
||||
|
||||
err = newUser.Validate()
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
if databaseUser.ID == "" {
|
||||
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
log.Printf("Skipping creating LDAP user '%s': %v", newUser.Username, err)
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
|
||||
@@ -356,7 +374,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
} else {
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
log.Printf("Skipping updating LDAP user '%s': %v", newUser.Username, err)
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
|
||||
@@ -369,7 +387,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
|
||||
if err != nil {
|
||||
// This is not a fatal error
|
||||
log.Printf("Error saving profile picture for user %s: %v", newUser.Username, err)
|
||||
slog.Warn("Error saving profile picture for user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -398,7 +416,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
|
||||
}
|
||||
|
||||
log.Printf("Disabled user '%s'", user.Username)
|
||||
slog.Info("Disabled user", slog.String("username", user.Username))
|
||||
} else {
|
||||
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
|
||||
target := &common.LdapUserUpdateError{}
|
||||
@@ -408,7 +426,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
||||
}
|
||||
|
||||
log.Printf("Deleted user '%s'", user.Username)
|
||||
slog.Info("Deleted user", slog.String("username", user.Username))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -476,7 +494,7 @@ func getDNProperty(property string, str string) string {
|
||||
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
|
||||
func convertLdapIdToString(ldapId string) string {
|
||||
if utf8.ValidString(ldapId) {
|
||||
return ldapId
|
||||
return norm.NFC.String(ldapId)
|
||||
}
|
||||
|
||||
// Try to parse as binary UUID (16 bytes)
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -38,9 +37,11 @@ const (
|
||||
GrantTypeAuthorizationCode = "authorization_code"
|
||||
GrantTypeRefreshToken = "refresh_token"
|
||||
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
|
||||
GrantTypeClientCredentials = "client_credentials"
|
||||
|
||||
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
|
||||
|
||||
AccessTokenDuration = time.Hour
|
||||
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
|
||||
DeviceCodeDuration = 15 * time.Minute
|
||||
)
|
||||
@@ -51,6 +52,7 @@ type OidcService struct {
|
||||
appConfigService *AppConfigService
|
||||
auditLogService *AuditLogService
|
||||
customClaimService *CustomClaimService
|
||||
webAuthnService *WebAuthnService
|
||||
|
||||
httpClient *http.Client
|
||||
jwkCache *jwk.Cache
|
||||
@@ -63,6 +65,7 @@ func NewOidcService(
|
||||
appConfigService *AppConfigService,
|
||||
auditLogService *AuditLogService,
|
||||
customClaimService *CustomClaimService,
|
||||
webAuthnService *WebAuthnService,
|
||||
) (s *OidcService, err error) {
|
||||
s = &OidcService{
|
||||
db: db,
|
||||
@@ -70,6 +73,7 @@ func NewOidcService(
|
||||
appConfigService: appConfigService,
|
||||
auditLogService: auditLogService,
|
||||
customClaimService: customClaimService,
|
||||
webAuthnService: webAuthnService,
|
||||
}
|
||||
|
||||
// Note: we don't pass the HTTP Client with OTel instrumented to this because requests are always made in background and not tied to a specific trace
|
||||
@@ -124,6 +128,16 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if client.RequiresReauthentication {
|
||||
if input.ReauthenticationToken == "" {
|
||||
return "", "", &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
// If the client is not public, the code challenge must be provided
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
@@ -150,20 +164,11 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
// Check if the user has already authorized the client with the given scope
|
||||
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
|
||||
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// If the user has not authorized the client, create a new authorization in the database
|
||||
if !hasAuthorizedClient {
|
||||
err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Create the authorization code
|
||||
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
|
||||
if err != nil {
|
||||
@@ -171,7 +176,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
}
|
||||
|
||||
// Log the authorization event
|
||||
if hasAuthorizedClient {
|
||||
if hasAlreadyAuthorizedClient {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
|
||||
} else {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventNewClientAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": client.Name}, tx)
|
||||
@@ -244,6 +249,8 @@ func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateToke
|
||||
return s.createTokenFromRefreshToken(ctx, input)
|
||||
case GrantTypeDeviceCode:
|
||||
return s.createTokenFromDeviceCode(ctx, input)
|
||||
case GrantTypeClientCredentials:
|
||||
return s.createTokenFromClientCredentials(ctx, input)
|
||||
default:
|
||||
return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
|
||||
}
|
||||
@@ -326,7 +333,35 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
|
||||
IdToken: idToken,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: time.Hour,
|
||||
ExpiresIn: AccessTokenDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
|
||||
client, err := s.verifyClientCredentialsInternal(ctx, s.db, clientAuthCredentialsFromCreateTokensDto(&input), false)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// GenerateOAuthAccessToken uses user.ID as a "sub" claim. Prefix is used to take those security considerations
|
||||
// into account: https://datatracker.ietf.org/doc/html/rfc9068#name-security-considerations
|
||||
dummyUser := model.User{
|
||||
Base: model.Base{ID: "client-" + client.ID},
|
||||
}
|
||||
|
||||
audClaim := client.ID
|
||||
if input.Resource != "" {
|
||||
audClaim = input.Resource
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
return CreatedTokens{
|
||||
AccessToken: accessToken,
|
||||
ExpiresIn: AccessTokenDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -400,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
||||
IdToken: idToken,
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpiresIn: time.Hour,
|
||||
ExpiresIn: AccessTokenDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -444,10 +479,9 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
||||
).
|
||||
First(&storedRefreshToken).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
|
||||
} else if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
@@ -462,6 +496,19 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// Load the profile, which we need for the ID token
|
||||
userClaims, err := s.getUserClaims(ctx, &storedRefreshToken.User, storedRefreshToken.Scopes(), tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// Generate a new ID token
|
||||
// There's no nonce here because we don't have one with the refresh token, but that's not required
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// Generate a new refresh token and invalidate the old one
|
||||
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
|
||||
if err != nil {
|
||||
@@ -485,7 +532,8 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
||||
return CreatedTokens{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: newRefreshToken,
|
||||
ExpiresIn: time.Hour,
|
||||
IdToken: idToken,
|
||||
ExpiresIn: AccessTokenDuration,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -651,8 +699,7 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
|
||||
}
|
||||
|
||||
// As allowedUserGroupsCount is not a column, we need to manually sort it
|
||||
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
|
||||
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && isValidSortDirection {
|
||||
if sortedPaginationRequest.Sort.Column == "allowedUserGroupsCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
|
||||
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").
|
||||
Group("oidc_clients.id").
|
||||
@@ -668,22 +715,28 @@ func (s *OidcService) ListClients(ctx context.Context, name string, sortedPagina
|
||||
|
||||
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
|
||||
client := model.OidcClient{
|
||||
CreatedByID: userID,
|
||||
Base: model.Base{
|
||||
ID: input.ID,
|
||||
},
|
||||
CreatedByID: utils.Ptr(userID),
|
||||
}
|
||||
updateOIDCClientModelFromDto(&client, &input)
|
||||
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
||||
|
||||
err := s.db.
|
||||
WithContext(ctx).
|
||||
Create(&client).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.OidcClient{}, &common.ClientIdAlreadyExistsError{}
|
||||
}
|
||||
return model.OidcClient{}, err
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientCreateDto) (model.OidcClient, error) {
|
||||
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -717,7 +770,7 @@ func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input d
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientCreateDto) {
|
||||
func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClientUpdateDto) {
|
||||
// Base fields
|
||||
client.Name = input.Name
|
||||
client.CallbackURLs = input.CallbackURLs
|
||||
@@ -725,19 +778,20 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
|
||||
client.IsPublic = input.IsPublic
|
||||
// PKCE is required for public clients
|
||||
client.PkceEnabled = input.IsPublic || input.PkceEnabled
|
||||
client.RequiresReauthentication = input.RequiresReauthentication
|
||||
client.LaunchURL = input.LaunchURL
|
||||
|
||||
// Credentials
|
||||
if len(input.Credentials.FederatedIdentities) > 0 {
|
||||
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
|
||||
for i, fi := range input.Credentials.FederatedIdentities {
|
||||
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
|
||||
Issuer: fi.Issuer,
|
||||
Audience: fi.Audience,
|
||||
Subject: fi.Subject,
|
||||
JWKS: fi.JWKS,
|
||||
}
|
||||
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
|
||||
for i, fi := range input.Credentials.FederatedIdentities {
|
||||
client.Credentials.FederatedIdentities[i] = model.OidcClientFederatedIdentity{
|
||||
Issuer: fi.Issuer,
|
||||
Audience: fi.Audience,
|
||||
Subject: fi.Subject,
|
||||
JWKS: fi.JWKS,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
|
||||
@@ -1180,9 +1234,13 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
|
||||
}()
|
||||
|
||||
var deviceAuth model.OidcDeviceCode
|
||||
if err := tx.WithContext(ctx).Preload("Client.AllowedUserGroups").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
|
||||
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
|
||||
return err
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("Client.AllowedUserGroups").
|
||||
First(&deviceAuth, "user_code = ?", userCode).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding device code: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
|
||||
@@ -1191,17 +1249,26 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
|
||||
|
||||
// Check if the user group is allowed to authorize the client
|
||||
var user model.User
|
||||
if err := tx.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error; err != nil {
|
||||
return err
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding user groups: %w", err)
|
||||
}
|
||||
|
||||
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
|
||||
return &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Preload("Client").First(&deviceAuth, "user_code = ?", userCode).Error; err != nil {
|
||||
log.Printf("Error finding device code with user_code %s: %v", userCode, err)
|
||||
return err
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Preload("Client").
|
||||
First(&deviceAuth, "user_code = ?", userCode).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("error finding device code: %w", err)
|
||||
}
|
||||
|
||||
if time.Now().After(deviceAuth.ExpiresAt.ToTime()) {
|
||||
@@ -1211,33 +1278,24 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
|
||||
deviceAuth.UserID = &userID
|
||||
deviceAuth.IsAuthorized = true
|
||||
|
||||
if err := tx.WithContext(ctx).Save(&deviceAuth).Error; err != nil {
|
||||
log.Printf("Error saving device auth: %v", err)
|
||||
return err
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Save(&deviceAuth).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("error saving device auth: %w", err)
|
||||
}
|
||||
|
||||
// Verify the update was successful
|
||||
var verifiedAuth model.OidcDeviceCode
|
||||
if err := tx.WithContext(ctx).First(&verifiedAuth, "device_code = ?", deviceAuth.DeviceCode).Error; err != nil {
|
||||
log.Printf("Error verifying update: %v", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Create user authorization if needed
|
||||
hasAuthorizedClient, err := s.hasAuthorizedClientInternal(ctx, deviceAuth.ClientID, userID, deviceAuth.Scope, tx)
|
||||
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !hasAuthorizedClient {
|
||||
err := s.createAuthorizedClientInternal(ctx, userID, deviceAuth.ClientID, deviceAuth.Scope, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
|
||||
auditLogData := model.AuditLogData{"clientName": deviceAuth.Client.Name}
|
||||
if hasAlreadyAuthorizedClient {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
|
||||
} else {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventDeviceCodeAuthorization, ipAddress, userAgent, userID, model.AuditLogData{"clientName": deviceAuth.Client.Name}, tx)
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventNewDeviceCodeAuthorization, ipAddress, userAgent, userID, auditLogData, tx)
|
||||
}
|
||||
|
||||
return tx.Commit().Error
|
||||
@@ -1313,6 +1371,112 @@ func (s *OidcService) ListAuthorizedClients(ctx context.Context, userID string,
|
||||
return authorizedClients, response, err
|
||||
}
|
||||
|
||||
func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string, clientID string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var authorizedClient model.UserAuthorizedOidcClient
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||
First(&authorizedClient).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).Delete(&authorizedClient).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID string, sortedPaginationRequest utils.SortedPaginationRequest) ([]dto.AccessibleOidcClientDto, utils.PaginationResponse, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var user model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Preload("UserGroups").
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
userGroupIDs := make([]string, len(user.UserGroups))
|
||||
for i, group := range user.UserGroups {
|
||||
userGroupIDs[i] = group.ID
|
||||
}
|
||||
|
||||
// Build the query for accessible clients
|
||||
query := tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.OidcClient{}).
|
||||
Preload("UserAuthorizedOidcClients", "user_id = ?", userID)
|
||||
|
||||
// If user has no groups, only return clients with no allowed user groups
|
||||
if len(userGroupIDs) == 0 {
|
||||
query = query.Where(`NOT EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`)
|
||||
} else {
|
||||
query = query.Where(`
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
) OR EXISTS (
|
||||
SELECT 1 FROM oidc_clients_allowed_user_groups
|
||||
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
|
||||
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs)
|
||||
}
|
||||
|
||||
var clients []model.OidcClient
|
||||
|
||||
// Handle custom sorting for lastUsedAt column
|
||||
var response utils.PaginationResponse
|
||||
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
|
||||
query = query.
|
||||
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
|
||||
Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction + " NULLS LAST")
|
||||
}
|
||||
|
||||
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
|
||||
if err != nil {
|
||||
return nil, utils.PaginationResponse{}, err
|
||||
}
|
||||
|
||||
dtos := make([]dto.AccessibleOidcClientDto, len(clients))
|
||||
for i, client := range clients {
|
||||
var lastUsedAt *datatype.DateTime
|
||||
if len(client.UserAuthorizedOidcClients) > 0 {
|
||||
lastUsedAt = &client.UserAuthorizedOidcClients[0].LastUsedAt
|
||||
}
|
||||
dtos[i] = dto.AccessibleOidcClientDto{
|
||||
OidcClientMetaDataDto: dto.OidcClientMetaDataDto{
|
||||
ID: client.ID,
|
||||
Name: client.Name,
|
||||
LaunchURL: client.LaunchURL,
|
||||
HasLogo: client.HasLogo,
|
||||
},
|
||||
LastUsedAt: lastUsedAt,
|
||||
}
|
||||
}
|
||||
|
||||
return dtos, response, err
|
||||
}
|
||||
|
||||
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
|
||||
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
||||
if err != nil {
|
||||
@@ -1348,14 +1512,37 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
|
||||
return signed, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) error {
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: scope,
|
||||
func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID string, clientID string, scope string, tx *gorm.DB) (hasAlreadyAuthorizedClient bool, err error) {
|
||||
|
||||
// Check if the user has already authorized the client with the given scope
|
||||
hasAlreadyAuthorizedClient, err = s.hasAuthorizedClientInternal(ctx, clientID, userID, scope, tx)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
err := tx.WithContext(ctx).
|
||||
if hasAlreadyAuthorizedClient {
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.UserAuthorizedOidcClient{}).
|
||||
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||
Update("last_used_at", datatype.DateTime(time.Now())).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
return hasAlreadyAuthorizedClient, err
|
||||
}
|
||||
|
||||
return hasAlreadyAuthorizedClient, nil
|
||||
}
|
||||
|
||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: scope,
|
||||
LastUsedAt: datatype.DateTime(time.Now()),
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "user_id"}, {Name: "client_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"scope"}),
|
||||
@@ -1363,7 +1550,7 @@ func (s *OidcService) createAuthorizedClientInternal(ctx context.Context, userID
|
||||
Create(&userAuthorizedClient).
|
||||
Error
|
||||
|
||||
return err
|
||||
return hasAlreadyAuthorizedClient, err
|
||||
}
|
||||
|
||||
type ClientAuthCredentials struct {
|
||||
@@ -1416,8 +1603,8 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
|
||||
|
||||
// Validate credentials based on the authentication method
|
||||
switch {
|
||||
// First, if we have a client secret, we validate it
|
||||
case input.ClientSecret != "":
|
||||
// First, if we have a client secret, we validate it unless client is marked as public
|
||||
case input.ClientSecret != "" && !client.IsPublic:
|
||||
err = bcrypt.CompareHashAndPassword([]byte(client.Secret), []byte(input.ClientSecret))
|
||||
if err != nil {
|
||||
return nil, &common.OidcClientSecretInvalidError{}
|
||||
@@ -1428,7 +1615,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
|
||||
case isClientAssertion:
|
||||
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
|
||||
if err != nil {
|
||||
log.Printf("Invalid assertion for client '%s': %v", client.ID, err)
|
||||
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
|
||||
return nil, &common.OidcClientAssertionInvalidError{}
|
||||
}
|
||||
return client, nil
|
||||
@@ -1552,7 +1739,7 @@ func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, er
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes string) (*dto.OidcClientPreviewDto, error) {
|
||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -1577,14 +1764,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
|
||||
return nil, &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
dummyAuthorizedClient := model.UserAuthorizedOidcClient{
|
||||
UserID: userID,
|
||||
ClientID: clientID,
|
||||
Scope: scopes,
|
||||
User: user,
|
||||
}
|
||||
|
||||
userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx)
|
||||
userClaims, err := s.getUserClaims(ctx, &user, scopes, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -1637,14 +1817,10 @@ func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx)
|
||||
|
||||
return s.getUserClaims(ctx, &authorizedOidcClient.User, authorizedOidcClient.Scopes(), tx)
|
||||
}
|
||||
|
||||
func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]any, error) {
|
||||
user := authorizedClient.User
|
||||
scopes := strings.Split(authorizedClient.Scope, " ")
|
||||
|
||||
func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scopes []string, tx *gorm.DB) (map[string]any, error) {
|
||||
claims := make(map[string]any, 10)
|
||||
|
||||
claims["sub"] = user.ID
|
||||
@@ -1662,13 +1838,6 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "profile") {
|
||||
// Add profile claims
|
||||
claims["given_name"] = user.FirstName
|
||||
claims["family_name"] = user.LastName
|
||||
claims["name"] = user.FullName()
|
||||
claims["preferred_username"] = user.Username
|
||||
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
|
||||
|
||||
// Add custom claims
|
||||
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
|
||||
if err != nil {
|
||||
@@ -1687,6 +1856,15 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
|
||||
claims[customClaim.Key] = customClaim.Value
|
||||
}
|
||||
}
|
||||
|
||||
// Add profile claims
|
||||
claims["given_name"] = user.FirstName
|
||||
claims["family_name"] = user.LastName
|
||||
claims["name"] = user.FullName()
|
||||
claims["display_name"] = user.DisplayName
|
||||
|
||||
claims["preferred_username"] = user.Username
|
||||
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
|
||||
}
|
||||
|
||||
if slices.Contains(scopes, "email") {
|
||||
@@ -1695,3 +1873,19 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID string, userID string) (bool, error) {
|
||||
var user model.User
|
||||
err := s.db.WithContext(ctx).Preload("UserGroups").First(&user, "id = ?", userID).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
var client model.OidcClient
|
||||
err = s.db.WithContext(ctx).Preload("AllowedUserGroups").First(&client, "id = ?", clientID).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return s.IsUserGroupAllowedToAuthorize(user, client), nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
@@ -148,6 +149,13 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a mock config and JwtService to test complete a token creation process
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
|
||||
})
|
||||
mockJwtService, err := NewJwtService(db, mockConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a mock HTTP client with custom transport to return the JWKS
|
||||
httpClient := &http.Client{
|
||||
Transport: &testutils.MockRoundTripper{
|
||||
@@ -162,8 +170,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
|
||||
// Init the OidcService
|
||||
s := &OidcService{
|
||||
db: db,
|
||||
httpClient: httpClient,
|
||||
db: db,
|
||||
jwtService: mockJwtService,
|
||||
appConfigService: mockConfig,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
s.jwkCache, err = s.getJWKCache(t.Context())
|
||||
require.NoError(t, err)
|
||||
@@ -171,8 +181,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
// Create the test clients
|
||||
// 1. Confidential client
|
||||
confidentialClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
|
||||
Name: "Confidential Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||
Name: "Confidential Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
},
|
||||
}, "test-user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -182,20 +194,24 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
|
||||
// 2. Public client
|
||||
publicClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
|
||||
Name: "Public Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
IsPublic: true,
|
||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||
Name: "Public Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
IsPublic: true,
|
||||
},
|
||||
}, "test-user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3. Confidential client with federated identity
|
||||
federatedClient, err := s.CreateClient(t.Context(), dto.OidcClientCreateDto{
|
||||
Name: "Federated Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||
Name: "Federated Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
},
|
||||
}, "test-user-id")
|
||||
require.NoError(t, err)
|
||||
|
||||
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientCreateDto{
|
||||
federatedClient, err = s.UpdateClient(t.Context(), federatedClient.ID, dto.OidcClientUpdateDto{
|
||||
Name: federatedClient.Name,
|
||||
CallbackURLs: federatedClient.CallbackURLs,
|
||||
Credentials: dto.OidcClientCredentialsDto{
|
||||
@@ -378,4 +394,119 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
assert.Equal(t, federatedClient.ID, client.ID)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Complete token creation flow", func(t *testing.T) {
|
||||
t.Run("Client Credentials flow", func(t *testing.T) {
|
||||
t.Run("Succeeds with valid secret", func(t *testing.T) {
|
||||
// Generate a token
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: confidentialClient.ID,
|
||||
ClientSecret: confidentialSecret,
|
||||
}
|
||||
token, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
|
||||
// Verify the token
|
||||
claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check the claims
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{confidentialClient.ID}, audience, "Audience should contain confidential client ID")
|
||||
})
|
||||
|
||||
t.Run("Fails with invalid secret", func(t *testing.T) {
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: confidentialClient.ID,
|
||||
ClientSecret: "invalid-secret",
|
||||
}
|
||||
_, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
|
||||
})
|
||||
|
||||
t.Run("Fails without client secret for public clients", func(t *testing.T) {
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: publicClient.ID,
|
||||
}
|
||||
_, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
|
||||
})
|
||||
|
||||
t.Run("Succeeds with valid assertion", func(t *testing.T) {
|
||||
// Create JWT for federated identity
|
||||
token, err := jwt.NewBuilder().
|
||||
Issuer(federatedClientIssuer).
|
||||
Audience([]string{federatedClientAudience}).
|
||||
Subject(federatedClient.ID).
|
||||
IssuedAt(time.Now()).
|
||||
Expiration(time.Now().Add(10 * time.Minute)).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate a token
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientAssertion: string(signedToken),
|
||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||
}
|
||||
createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
|
||||
// Verify the token
|
||||
claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check the claims
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
|
||||
})
|
||||
|
||||
t.Run("Fails with invalid assertion", func(t *testing.T) {
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientAssertion: "invalid.jwt.token",
|
||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||
}
|
||||
_, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
|
||||
})
|
||||
|
||||
t.Run("Succeeds with custom resource", func(t *testing.T) {
|
||||
// Generate a token
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: confidentialClient.ID,
|
||||
ClientSecret: confidentialSecret,
|
||||
Resource: "https://example.com/",
|
||||
}
|
||||
token, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
|
||||
// Verify the token
|
||||
claims, err := s.jwtService.VerifyOAuthAccessToken(token.AccessToken)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check the claims
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "client-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{input.Resource}, audience, "Audience should contain the resource provided in request")
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ func (s *UserGroupService) List(ctx context.Context, name string, sortedPaginati
|
||||
}
|
||||
|
||||
// As userCount is not a column we need to manually sort it
|
||||
isValidSortDirection := sortedPaginationRequest.Sort.Direction == "asc" || sortedPaginationRequest.Sort.Direction == "desc"
|
||||
if sortedPaginationRequest.Sort.Column == "userCount" && isValidSortDirection {
|
||||
if sortedPaginationRequest.Sort.Column == "userCount" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
|
||||
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").
|
||||
Group("user_groups.id").
|
||||
|
||||
@@ -3,16 +3,18 @@ package service
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -25,15 +27,23 @@ import (
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
db *gorm.DB
|
||||
jwtService *JwtService
|
||||
auditLogService *AuditLogService
|
||||
emailService *EmailService
|
||||
appConfigService *AppConfigService
|
||||
customClaimService *CustomClaimService
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService) *UserService {
|
||||
return &UserService{db: db, jwtService: jwtService, auditLogService: auditLogService, emailService: emailService, appConfigService: appConfigService}
|
||||
func NewUserService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, emailService *EmailService, appConfigService *AppConfigService, customClaimService *CustomClaimService) *UserService {
|
||||
return &UserService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
emailService: emailService,
|
||||
appConfigService: appConfigService,
|
||||
customClaimService: customClaimService,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPaginationRequest utils.SortedPaginationRequest) ([]model.User, utils.PaginationResponse, error) {
|
||||
@@ -45,7 +55,8 @@ func (s *UserService) ListUsers(ctx context.Context, searchTerm string, sortedPa
|
||||
|
||||
if searchTerm != "" {
|
||||
searchPattern := "%" + searchTerm + "%"
|
||||
query = query.Where("email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
|
||||
query = query.Where(
|
||||
"email LIKE ? OR first_name LIKE ? OR last_name LIKE ? OR username LIKE ?",
|
||||
searchPattern, searchPattern, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
@@ -118,13 +129,14 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
|
||||
defaultPictureBytes := defaultPicture.Bytes()
|
||||
go func() {
|
||||
// Ensure the directory exists
|
||||
err = os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
|
||||
if err != nil {
|
||||
log.Printf("Failed to create directory for default profile picture: %v", err)
|
||||
errInternal := os.MkdirAll(defaultProfilePicturesDir, os.ModePerm)
|
||||
if errInternal != nil {
|
||||
slog.Error("Failed to create directory for default profile picture", slog.Any("error", errInternal))
|
||||
return
|
||||
}
|
||||
if err := utils.SaveFileStream(bytes.NewReader(defaultPictureBytes), defaultPicturePath); err != nil {
|
||||
log.Printf("Failed to cache default profile picture for initials %s: %v", user.Initials(), err)
|
||||
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))
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -233,12 +245,13 @@ 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) {
|
||||
user := model.User{
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
Email: input.Email,
|
||||
Username: input.Username,
|
||||
IsAdmin: input.IsAdmin,
|
||||
Locale: input.Locale,
|
||||
FirstName: input.FirstName,
|
||||
LastName: input.LastName,
|
||||
DisplayName: input.DisplayName,
|
||||
Email: input.Email,
|
||||
Username: input.Username,
|
||||
IsAdmin: input.IsAdmin,
|
||||
Locale: input.Locale,
|
||||
}
|
||||
if input.LdapID != "" {
|
||||
user.LdapID = &input.LdapID
|
||||
@@ -259,9 +272,53 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
} else if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
if !isLdapSync {
|
||||
if err := s.applySignupDefaults(ctx, &user, tx); err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Apply default user groups
|
||||
var groupIDs []string
|
||||
if v := config.SignupDefaultUserGroupIDs.Value; v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &groupIDs); err != nil {
|
||||
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
|
||||
}
|
||||
if len(groupIDs) > 0 {
|
||||
var groups []model.UserGroup
|
||||
if err := tx.WithContext(ctx).Where("id IN ?", groupIDs).Find(&groups).Error; err != nil {
|
||||
return fmt.Errorf("failed to find default user groups: %w", err)
|
||||
}
|
||||
if err := tx.WithContext(ctx).Model(user).Association("UserGroups").Replace(groups); err != nil {
|
||||
return fmt.Errorf("failed to associate default user groups: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default custom claims
|
||||
var claims []dto.CustomClaimCreateDto
|
||||
if v := config.SignupDefaultCustomClaims.Value; v != "" && v != "[]" {
|
||||
if err := json.Unmarshal([]byte(v), &claims); err != nil {
|
||||
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
|
||||
}
|
||||
if len(claims) > 0 {
|
||||
if _, err := s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx); err != nil {
|
||||
return fmt.Errorf("failed to apply default custom claims: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateUser(ctx context.Context, userID string, updatedUser dto.UserCreateDto, updateOwnUser bool, isLdapSync bool) (model.User, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
@@ -306,6 +363,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
// Full update: Allow updating all personal fields
|
||||
user.FirstName = updatedUser.FirstName
|
||||
user.LastName = updatedUser.LastName
|
||||
user.DisplayName = updatedUser.DisplayName
|
||||
user.Email = updatedUser.Email
|
||||
user.Username = updatedUser.Username
|
||||
user.Locale = updatedUser.Locale
|
||||
@@ -339,13 +397,13 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
return user, nil
|
||||
}
|
||||
|
||||
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, expiration time.Time) error {
|
||||
func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, userID string, ttl time.Duration) error {
|
||||
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsAdminEnabled.IsTrue()
|
||||
if isDisabled {
|
||||
return &common.OneTimeAccessDisabledError{}
|
||||
}
|
||||
|
||||
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", expiration)
|
||||
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl)
|
||||
}
|
||||
|
||||
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
|
||||
@@ -365,11 +423,10 @@ func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context
|
||||
}
|
||||
}
|
||||
|
||||
expiration := time.Now().Add(15 * time.Minute)
|
||||
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, expiration)
|
||||
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
|
||||
}
|
||||
|
||||
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, expiration time.Time) error {
|
||||
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -380,7 +437,7 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
||||
return err
|
||||
}
|
||||
|
||||
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, expiration, tx)
|
||||
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -393,7 +450,8 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
||||
// We use a background context here as this is running in a goroutine
|
||||
//nolint:contextcheck
|
||||
go func() {
|
||||
innerCtx := context.Background()
|
||||
span := trace.SpanFromContext(ctx)
|
||||
innerCtx := trace.ContextWithSpan(context.Background(), span)
|
||||
|
||||
link := common.EnvConfig.AppURL + "/lc"
|
||||
linkWithCode := link + "/" + oneTimeAccessToken
|
||||
@@ -411,27 +469,29 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
|
||||
Code: oneTimeAccessToken,
|
||||
LoginLink: link,
|
||||
LoginLinkWithCode: linkWithCode,
|
||||
ExpirationString: utils.DurationToString(time.Until(expiration).Round(time.Second)),
|
||||
ExpirationString: utils.DurationToString(ttl),
|
||||
})
|
||||
if errInternal != nil {
|
||||
log.Printf("Failed to send email to '%s': %v\n", user.Email, errInternal)
|
||||
slog.ErrorContext(innerCtx, "Failed to send one-time access token email", slog.Any("error", errInternal), slog.String("address", user.Email))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, expiresAt time.Time) (string, error) {
|
||||
return s.createOneTimeAccessTokenInternal(ctx, userID, expiresAt, s.db)
|
||||
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error) {
|
||||
return s.createOneTimeAccessTokenInternal(ctx, userID, ttl, s.db)
|
||||
}
|
||||
|
||||
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, expiresAt time.Time, tx *gorm.DB) (string, error) {
|
||||
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, expiresAt)
|
||||
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, tx *gorm.DB) (string, error) {
|
||||
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Create(oneTimeAccessToken).Error; err != nil {
|
||||
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -469,9 +529,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token stri
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
if ipAddress != "" && userAgent != "" {
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
|
||||
}
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventOneTimeAccessTokenSignIn, ipAddress, userAgent, oneTimeAccessToken.User.ID, model.AuditLogData{}, tx)
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
@@ -495,7 +553,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
||||
// Fetch the groups based on userGroupIds
|
||||
var groups []model.UserGroup
|
||||
if len(userGroupIds) > 0 {
|
||||
err = tx.
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Where("id IN (?)", userGroupIds).
|
||||
Find(&groups).
|
||||
@@ -544,11 +602,12 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
FirstName: signUpData.FirstName,
|
||||
LastName: signUpData.LastName,
|
||||
DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
|
||||
Username: signUpData.Username,
|
||||
Email: signUpData.Email,
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -633,17 +692,14 @@ func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, expiresAt time.Time, usageLimit int) (model.SignupToken, error) {
|
||||
return s.createSignupTokenInternal(ctx, expiresAt, usageLimit, s.db)
|
||||
}
|
||||
|
||||
func (s *UserService) createSignupTokenInternal(ctx context.Context, expiresAt time.Time, usageLimit int, tx *gorm.DB) (model.SignupToken, error) {
|
||||
signupToken, err := NewSignupToken(expiresAt, usageLimit)
|
||||
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) {
|
||||
signupToken, err := NewSignupToken(ttl, usageLimit)
|
||||
if err != nil {
|
||||
return model.SignupToken{}, err
|
||||
}
|
||||
|
||||
if err := tx.WithContext(ctx).Create(signupToken).Error; err != nil {
|
||||
err = s.db.WithContext(ctx).Create(signupToken).Error
|
||||
if err != nil {
|
||||
return model.SignupToken{}, err
|
||||
}
|
||||
|
||||
@@ -683,10 +739,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
Username: signupData.Username,
|
||||
Email: signupData.Email,
|
||||
FirstName: signupData.FirstName,
|
||||
LastName: signupData.LastName,
|
||||
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
|
||||
}
|
||||
|
||||
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
|
||||
@@ -737,10 +794,10 @@ func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) err
|
||||
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
|
||||
}
|
||||
|
||||
func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAccessToken, error) {
|
||||
func NewOneTimeAccessToken(userID string, ttl time.Duration) (*model.OneTimeAccessToken, error) {
|
||||
// If expires at is less than 15 minutes, use a 6-character token instead of 16
|
||||
tokenLength := 16
|
||||
if time.Until(expiresAt) <= 15*time.Minute {
|
||||
if ttl <= 15*time.Minute {
|
||||
tokenLength = 6
|
||||
}
|
||||
|
||||
@@ -749,25 +806,27 @@ func NewOneTimeAccessToken(userID string, expiresAt time.Time) (*model.OneTimeAc
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
o := &model.OneTimeAccessToken{
|
||||
UserID: userID,
|
||||
ExpiresAt: datatype.DateTime(expiresAt),
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
Token: randomString,
|
||||
}
|
||||
|
||||
return o, nil
|
||||
}
|
||||
|
||||
func NewSignupToken(expiresAt time.Time, usageLimit int) (*model.SignupToken, error) {
|
||||
func NewSignupToken(ttl time.Duration, usageLimit int) (*model.SignupToken, error) {
|
||||
// Generate a random token
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(16)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().Round(time.Second)
|
||||
token := &model.SignupToken{
|
||||
Token: randomString,
|
||||
ExpiresAt: datatype.DateTime(expiresAt),
|
||||
ExpiresAt: datatype.DateTime(now.Add(ttl)),
|
||||
UsageLimit: usageLimit,
|
||||
UsageCount: 0,
|
||||
}
|
||||
|
||||
74
backend/internal/service/version_service.go
Normal file
74
backend/internal/service/version_service.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
const (
|
||||
versionTTL = 15 * time.Minute
|
||||
versionCheckURL = "https://api.github.com/repos/pocket-id/pocket-id/releases/latest"
|
||||
)
|
||||
|
||||
type VersionService struct {
|
||||
httpClient *http.Client
|
||||
cache *utils.Cache[string]
|
||||
}
|
||||
|
||||
func NewVersionService(httpClient *http.Client) *VersionService {
|
||||
return &VersionService{
|
||||
httpClient: httpClient,
|
||||
cache: utils.New[string](versionTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
|
||||
version, err := s.cache.GetOrFetch(ctx, func(ctx context.Context) (string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, versionCheckURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create GitHub request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get latest tag: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
TagName string `json:"tag_name"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
|
||||
return "", fmt.Errorf("decode payload: %w", err)
|
||||
}
|
||||
|
||||
if payload.TagName == "" {
|
||||
return "", fmt.Errorf("GitHub API returned empty tag name")
|
||||
}
|
||||
|
||||
return strings.TrimPrefix(payload.TagName, "v"), nil
|
||||
})
|
||||
|
||||
var staleErr *utils.ErrStale
|
||||
if errors.As(err, &staleErr) {
|
||||
slog.Warn("Failed to fetch latest version, returning stale cache", "error", staleErr.Err)
|
||||
return version, nil
|
||||
}
|
||||
|
||||
return version, err
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -24,8 +25,8 @@ type WebAuthnService struct {
|
||||
appConfigService *AppConfigService
|
||||
}
|
||||
|
||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) *WebAuthnService {
|
||||
webauthnConfig := &webauthn.Config{
|
||||
func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *AuditLogService, appConfigService *AppConfigService) (*WebAuthnService, error) {
|
||||
wa, err := webauthn.New(&webauthn.Config{
|
||||
RPDisplayName: appConfigService.GetDbConfig().AppName.Value,
|
||||
RPID: utils.GetHostnameFromURL(common.EnvConfig.AppURL),
|
||||
RPOrigins: []string{common.EnvConfig.AppURL},
|
||||
@@ -44,15 +45,18 @@ func NewWebAuthnService(db *gorm.DB, jwtService *JwtService, auditLogService *Au
|
||||
TimeoutUVD: time.Second * 60,
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to init webauthn object: %w", err)
|
||||
}
|
||||
wa, _ := webauthn.New(webauthnConfig)
|
||||
|
||||
return &WebAuthnService{
|
||||
db: db,
|
||||
webAuthn: wa,
|
||||
jwtService: jwtService,
|
||||
auditLogService: auditLogService,
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string) (*model.PublicKeyCredentialCreationOptions, error) {
|
||||
@@ -70,8 +74,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
|
||||
Find(&user, "id = ?", userID).
|
||||
Error
|
||||
if err != nil {
|
||||
tx.Rollback()
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
|
||||
options, session, err := s.webAuthn.BeginRegistration(
|
||||
@@ -80,7 +83,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
|
||||
webauthn.WithExclusions(user.WebAuthnCredentialDescriptors()),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to begin WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
sessionToStore := &model.WebauthnSession{
|
||||
@@ -94,12 +97,12 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
|
||||
Create(&sessionToStore).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to save WebAuthn session: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return &model.PublicKeyCredentialCreationOptions{
|
||||
@@ -115,13 +118,15 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Load & delete the session row
|
||||
var storedSession model.WebauthnSession
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
First(&storedSession, "id = ?", sessionID).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(&storedSession, "id = ?", sessionID).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, err
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to load WebAuthn session: %w", err)
|
||||
}
|
||||
|
||||
session := webauthn.SessionData{
|
||||
@@ -136,12 +141,12 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
|
||||
Find(&user, "id = ?", userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, err
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
|
||||
credential, err := s.webAuthn.FinishRegistration(&user, session, r)
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, err
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to finish WebAuthn registration: %w", err)
|
||||
}
|
||||
|
||||
// Determine passkey name using AAGUID and User-Agent
|
||||
@@ -162,12 +167,12 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, use
|
||||
Create(&credentialToStore).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, err
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to store WebAuthn credential: %w", err)
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return model.WebauthnCredential{}, err
|
||||
return model.WebauthnCredential{}, fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
|
||||
return credentialToStore, nil
|
||||
@@ -216,13 +221,15 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Load & delete the session row
|
||||
var storedSession model.WebauthnSession
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
First(&storedSession, "id = ?", sessionID).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(&storedSession, "id = ?", sessionID).
|
||||
Error
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
return model.User{}, "", fmt.Errorf("failed to load WebAuthn session: %w", err)
|
||||
}
|
||||
|
||||
session := webauthn.SessionData{
|
||||
@@ -329,3 +336,136 @@ func (s *WebAuthnService) UpdateCredential(ctx context.Context, userID, credenti
|
||||
func (s *WebAuthnService) updateWebAuthnConfig() {
|
||||
s.webAuthn.Config.RPDisplayName = s.appConfigService.GetDbConfig().AppName.Value
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context.Context, accessToken string) (string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
token, err := s.jwtService.VerifyAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("invalid access token: %w", err)
|
||||
}
|
||||
|
||||
userID, ok := token.Subject()
|
||||
if !ok {
|
||||
return "", fmt.Errorf("access token does not contain user ID")
|
||||
}
|
||||
|
||||
// Check if token is issued less than a minute ago
|
||||
tokenExpiration, ok := token.IssuedAt()
|
||||
if !ok || time.Since(tokenExpiration) > time.Minute {
|
||||
return "", &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
First(&user, "id = ?", userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load user: %w", err)
|
||||
}
|
||||
|
||||
reauthToken, err := s.createReauthenticationToken(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return reauthToken, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) CreateReauthenticationTokenWithWebauthn(ctx context.Context, sessionID string, credentialAssertionData *protocol.ParsedCredentialAssertionData) (string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Retrieve and delete the session
|
||||
var storedSession model.WebauthnSession
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(&storedSession, "id = ? AND expires_at > ?", sessionID, datatype.DateTime(time.Now())).
|
||||
Error
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to load WebAuthn session: %w", err)
|
||||
}
|
||||
|
||||
session := webauthn.SessionData{
|
||||
Challenge: storedSession.Challenge,
|
||||
Expires: storedSession.ExpiresAt.ToTime(),
|
||||
}
|
||||
|
||||
// Validate the credential assertion
|
||||
var user *model.User
|
||||
_, err = s.webAuthn.ValidateDiscoverableLogin(func(_, userHandle []byte) (webauthn.User, error) {
|
||||
innerErr := tx.
|
||||
WithContext(ctx).
|
||||
Preload("Credentials").
|
||||
First(&user, "id = ?", string(userHandle)).
|
||||
Error
|
||||
if innerErr != nil {
|
||||
return nil, innerErr
|
||||
}
|
||||
return user, nil
|
||||
}, session, credentialAssertionData)
|
||||
|
||||
if err != nil || user == nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create reauthentication token
|
||||
token, err := s.createReauthenticationToken(ctx, tx, user.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) ConsumeReauthenticationToken(ctx context.Context, tx *gorm.DB, token string, userID string) error {
|
||||
hashedToken := utils.CreateSha256Hash(token)
|
||||
result := tx.WithContext(ctx).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(&model.ReauthenticationToken{}, "token = ? AND user_id = ? AND expires_at > ?", hashedToken, userID, datatype.DateTime(time.Now()))
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) createReauthenticationToken(ctx context.Context, tx *gorm.DB, userID string) (string, error) {
|
||||
token, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
reauthToken := model.ReauthenticationToken{
|
||||
Token: utils.CreateSha256Hash(token),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(3 * time.Minute)),
|
||||
UserID: userID,
|
||||
}
|
||||
|
||||
err = tx.WithContext(ctx).Create(&reauthToken).Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
@@ -57,12 +57,13 @@ func loadAAGUIDsFromFile() {
|
||||
// Read from embedded file system
|
||||
data, err := resources.FS.ReadFile("aaguids.json")
|
||||
if err != nil {
|
||||
log.Printf("Error reading embedded AAGUID file: %v", err)
|
||||
slog.Error("Error reading embedded AAGUID file", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := json.Unmarshal(data, &aaguidMap); err != nil {
|
||||
log.Printf("Error unmarshalling AAGUID data: %v", err)
|
||||
err = json.Unmarshal(data, &aaguidMap)
|
||||
if err != nil {
|
||||
slog.Error("Error unmarshalling AAGUID data", slog.Any("error", err))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
78
backend/internal/utils/cache_util.go
Normal file
78
backend/internal/utils/cache_util.go
Normal file
@@ -0,0 +1,78 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
type CacheEntry[T any] struct {
|
||||
Value T
|
||||
FetchedAt time.Time
|
||||
}
|
||||
|
||||
type ErrStale struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
func (e *ErrStale) Error() string { return "returned stale cache: " + e.Err.Error() }
|
||||
func (e *ErrStale) Unwrap() error { return e.Err }
|
||||
|
||||
type Cache[T any] struct {
|
||||
ttl time.Duration
|
||||
entry atomic.Pointer[CacheEntry[T]]
|
||||
sf singleflight.Group
|
||||
}
|
||||
|
||||
func New[T any](ttl time.Duration) *Cache[T] {
|
||||
return &Cache[T]{ttl: ttl}
|
||||
}
|
||||
|
||||
// Get returns the cached value if it's still fresh.
|
||||
func (c *Cache[T]) Get() (T, bool) {
|
||||
entry := c.entry.Load()
|
||||
if entry == nil {
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
if time.Since(entry.FetchedAt) < c.ttl {
|
||||
return entry.Value, true
|
||||
}
|
||||
var zero T
|
||||
return zero, false
|
||||
}
|
||||
|
||||
// GetOrFetch returns the cached value if it's still fresh, otherwise calls fetch to get a new value.
|
||||
func (c *Cache[T]) GetOrFetch(ctx context.Context, fetch func(context.Context) (T, error)) (T, error) {
|
||||
// If fresh, serve immediately
|
||||
if v, ok := c.Get(); ok {
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// Fetch with singleflight to prevent multiple concurrent fetches
|
||||
vAny, err, _ := c.sf.Do("singleton", func() (any, error) {
|
||||
if v2, ok := c.Get(); ok {
|
||||
return v2, nil
|
||||
}
|
||||
val, fetchErr := fetch(ctx)
|
||||
if fetchErr != nil {
|
||||
return nil, fetchErr
|
||||
}
|
||||
c.entry.Store(&CacheEntry[T]{Value: val, FetchedAt: time.Now()})
|
||||
return val, nil
|
||||
})
|
||||
|
||||
if err == nil {
|
||||
return vAny.(T), nil
|
||||
}
|
||||
|
||||
// Fetch failed. Return stale if possible.
|
||||
if e := c.entry.Load(); e != nil {
|
||||
return e.Value, &ErrStale{Err: err}
|
||||
}
|
||||
|
||||
var zero T
|
||||
return zero, err
|
||||
}
|
||||
@@ -3,8 +3,7 @@ package email
|
||||
import (
|
||||
"fmt"
|
||||
htemplate "html/template"
|
||||
"io/fs"
|
||||
"path"
|
||||
"path/filepath"
|
||||
ttemplate "text/template"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
@@ -27,71 +26,35 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
|
||||
return templateMap[template.Path]
|
||||
}
|
||||
|
||||
type cloneable[V pareseable[V]] interface {
|
||||
Clone() (V, error)
|
||||
}
|
||||
|
||||
type pareseable[V any] interface {
|
||||
ParseFS(fs.FS, ...string) (V, error)
|
||||
}
|
||||
|
||||
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
|
||||
tmpl, err := rootTemplate.Clone()
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("clone root template: %w", err)
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s%s", template, suffix)
|
||||
templatePath := path.Join("email-templates", filename)
|
||||
_, err = tmpl.ParseFS(templateFS, templatePath)
|
||||
if err != nil {
|
||||
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
|
||||
}
|
||||
|
||||
return tmpl, nil
|
||||
}
|
||||
|
||||
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
|
||||
components := path.Join("email-templates", "components", "*_text.tmpl")
|
||||
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||
}
|
||||
|
||||
textTemplates := make(map[string]*ttemplate.Template, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
rootTmplClone, err := rootTmpl.Clone()
|
||||
filename := tmpl + "_text.tmpl"
|
||||
templatePath := filepath.Join("email-templates", filename)
|
||||
|
||||
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone root template: %w", err)
|
||||
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
|
||||
}
|
||||
|
||||
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||
}
|
||||
textTemplates[tmpl] = parsedTemplate
|
||||
}
|
||||
|
||||
return textTemplates, nil
|
||||
}
|
||||
|
||||
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
|
||||
components := path.Join("email-templates", "components", "*_html.tmpl")
|
||||
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
|
||||
}
|
||||
|
||||
htmlTemplates := make(map[string]*htemplate.Template, len(templates))
|
||||
for _, tmpl := range templates {
|
||||
rootTmplClone, err := rootTmpl.Clone()
|
||||
filename := tmpl + "_html.tmpl"
|
||||
templatePath := filepath.Join("email-templates", filename)
|
||||
|
||||
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("clone root template: %w", err)
|
||||
return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
|
||||
}
|
||||
|
||||
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
|
||||
}
|
||||
htmlTemplates[tmpl] = parsedTemplate
|
||||
}
|
||||
|
||||
return htmlTemplates, nil
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
@@ -32,6 +36,12 @@ func GetImageMimeType(ext string) string {
|
||||
return "image/x-icon"
|
||||
case "gif":
|
||||
return "image/gif"
|
||||
case "webp":
|
||||
return "image/webp"
|
||||
case "avif":
|
||||
return "image/avif"
|
||||
case "heic":
|
||||
return "image/heic"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
@@ -40,29 +50,45 @@ func GetImageMimeType(ext string) string {
|
||||
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
|
||||
srcFile, err := resources.FS.Open(srcFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to open embedded file: %w", err)
|
||||
}
|
||||
defer srcFile.Close()
|
||||
|
||||
err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to create destination directory: %w", err)
|
||||
}
|
||||
|
||||
destFile, err := os.Create(destFilePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to open destination file: %w", err)
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
_, err = io.Copy(destFile, srcFile)
|
||||
if err != nil {
|
||||
return err
|
||||
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 {
|
||||
@@ -136,3 +162,41 @@ func FileExists(path string) (bool, error) {
|
||||
}
|
||||
return !s.IsDir(), nil
|
||||
}
|
||||
|
||||
// IsWritableDir checks if a directory exists and is writable
|
||||
func IsWritableDir(dir string) (bool, error) {
|
||||
// Check if directory exists and it's actually a directory
|
||||
info, err := os.Stat(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
} else if err != nil {
|
||||
return false, fmt.Errorf("failed to stat '%s': %w", dir, err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Generate a random suffix for the test file to avoid conflicts
|
||||
randomBytes := make([]byte, 8)
|
||||
_, err = io.ReadFull(rand.Reader, randomBytes)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to generate random bytes: %w", err)
|
||||
}
|
||||
|
||||
// Check if directory is writable by trying to create a temporary file
|
||||
testFile := filepath.Join(dir, ".pocketid_test_write_"+hex.EncodeToString(randomBytes))
|
||||
defer os.Remove(testFile)
|
||||
|
||||
file, err := os.Create(testFile)
|
||||
if err != nil {
|
||||
if os.IsPermission(err) || errors.Is(err, syscall.EROFS) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("failed to create test file: %w", err)
|
||||
}
|
||||
|
||||
_ = file.Close()
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
@@ -3,9 +3,28 @@ package utils
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func CreateSha256Hash(input string) string {
|
||||
hash := sha256.Sum256([]byte(input))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func CreateSha256FileHash(filePath string) ([]byte, error) {
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
h := sha256.New()
|
||||
_, err = io.Copy(h, f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
return h.Sum(nil), nil
|
||||
}
|
||||
|
||||
@@ -29,9 +29,9 @@ func CreateProfilePicture(file io.Reader) (io.Reader, error) {
|
||||
|
||||
pr, pw := io.Pipe()
|
||||
go func() {
|
||||
err = imaging.Encode(pw, img, imaging.PNG)
|
||||
if err != nil {
|
||||
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", err))
|
||||
innerErr := imaging.Encode(pw, img, imaging.PNG)
|
||||
if innerErr != nil {
|
||||
_ = pw.CloseWithError(fmt.Errorf("failed to encode image: %w", innerErr))
|
||||
return
|
||||
}
|
||||
pw.Close()
|
||||
|
||||
42
backend/internal/utils/json_util.go
Normal file
42
backend/internal/utils/json_util.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
// JSONDuration is a type that allows marshalling/unmarshalling a Duration
|
||||
type JSONDuration struct {
|
||||
time.Duration
|
||||
}
|
||||
|
||||
func (d JSONDuration) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal(d.String())
|
||||
}
|
||||
|
||||
func (d *JSONDuration) UnmarshalJSON(b []byte) error {
|
||||
var v any
|
||||
err := json.Unmarshal(b, &v)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
switch value := v.(type) {
|
||||
case float64:
|
||||
// If the value is a number, interpret it as a number of seconds
|
||||
d.Duration = time.Duration(value) * time.Second
|
||||
return nil
|
||||
case string:
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
var err error
|
||||
d.Duration, err = time.ParseDuration(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid duration")
|
||||
}
|
||||
}
|
||||
64
backend/internal/utils/json_util_test.go
Normal file
64
backend/internal/utils/json_util_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestJSONDuration_MarshalJSON(t *testing.T) {
|
||||
tests := []struct {
|
||||
duration time.Duration
|
||||
want string
|
||||
}{
|
||||
{time.Minute + 30*time.Second, "1m30s"},
|
||||
{0, "0s"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
d := JSONDuration{Duration: tc.duration}
|
||||
b, err := json.Marshal(d)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, `"`+tc.want+`"`, string(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONDuration_UnmarshalJSON_String(t *testing.T) {
|
||||
var d JSONDuration
|
||||
err := json.Unmarshal([]byte(`"2h15m5s"`), &d)
|
||||
require.NoError(t, err)
|
||||
want := 2*time.Hour + 15*time.Minute + 5*time.Second
|
||||
assert.Equal(t, want, d.Duration)
|
||||
}
|
||||
|
||||
func TestJSONDuration_UnmarshalJSON_NumberSeconds(t *testing.T) {
|
||||
tests := []struct {
|
||||
json string
|
||||
want time.Duration
|
||||
}{
|
||||
{"0", 0},
|
||||
{"1", 1 * time.Second},
|
||||
{"2.25", 2 * time.Second}, // Milliseconds are truncated
|
||||
}
|
||||
for _, tc := range tests {
|
||||
var d JSONDuration
|
||||
err := json.Unmarshal([]byte(tc.json), &d)
|
||||
require.NoError(t, err, "input: %s", tc.json)
|
||||
assert.Equal(t, tc.want, d.Duration, "input: %s", tc.json)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONDuration_UnmarshalJSON_Invalid(t *testing.T) {
|
||||
cases := [][]byte{
|
||||
[]byte(`true`),
|
||||
[]byte(`{}`),
|
||||
[]byte(`"not-a-duration"`),
|
||||
}
|
||||
for _, b := range cases {
|
||||
var d JSONDuration
|
||||
err := json.Unmarshal(b, &d)
|
||||
require.Error(t, err, "input: %s", string(b))
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||
@@ -95,7 +96,14 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
err = f.db.WithContext(ctx).Create(&row).Error
|
||||
err = f.db.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"value"}),
|
||||
}).
|
||||
Create(&row).
|
||||
Error
|
||||
if err != nil {
|
||||
// There's one scenario where if Pocket ID is started fresh with more than 1 replica, they both could be trying to create the private key in the database at the same time
|
||||
// In this case, only one of the replicas will succeed; the other one(s) will return an error here, which will cascade down and cause the replica(s) to crash and be restarted (at that point they'll load the then-existing key from the database)
|
||||
|
||||
@@ -15,7 +15,6 @@ import (
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
@@ -47,26 +46,15 @@ func EncodeJWKBytes(key jwk.Key) ([]byte, error) {
|
||||
|
||||
// LoadKeyEncryptionKey loads the key encryption key for JWKs
|
||||
func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) (kek []byte, err error) {
|
||||
// Try getting the key from the env var as string
|
||||
kekInput := []byte(envConfig.EncryptionKey)
|
||||
|
||||
// If there's nothing in the env, try loading from file
|
||||
if len(kekInput) == 0 && envConfig.EncryptionKeyFile != "" {
|
||||
kekInput, err = os.ReadFile(envConfig.EncryptionKeyFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key file '%s': %w", envConfig.EncryptionKeyFile, err)
|
||||
}
|
||||
}
|
||||
|
||||
// If there's still no key, return
|
||||
if len(kekInput) == 0 {
|
||||
// If there's no key, return
|
||||
if len(envConfig.EncryptionKey) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// We need a 256-bit key for encryption with AES-GCM-256
|
||||
// We use HMAC with SHA3-256 here to derive the key from the one passed as input
|
||||
// The key is tied to a specific instance of Pocket ID
|
||||
h := hmac.New(func() hash.Hash { return sha3.New256() }, kekInput)
|
||||
h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(envConfig.EncryptionKey))
|
||||
fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek")
|
||||
kek = h.Sum(nil)
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package utils
|
||||
import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
@@ -35,9 +36,7 @@ func PaginateAndSort(sortedPaginationRequest SortedPaginationRequest, query *gor
|
||||
sortField, sortFieldFound := reflect.TypeOf(result).Elem().Elem().FieldByName(capitalizedSortColumn)
|
||||
isSortable, _ := strconv.ParseBool(sortField.Tag.Get("sortable"))
|
||||
|
||||
if sort.Direction == "" || (sort.Direction != "asc" && sort.Direction != "desc") {
|
||||
sort.Direction = "asc"
|
||||
}
|
||||
sort.Direction = NormalizeSortDirection(sort.Direction)
|
||||
|
||||
if sortFieldFound && isSortable {
|
||||
columnName := CamelCaseToSnakeCase(sort.Column)
|
||||
@@ -85,3 +84,16 @@ func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (Pagin
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -3,3 +3,11 @@ package utils
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
func PtrValueOrZero[T any](ptr *T) T {
|
||||
if ptr == nil {
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
return *ptr
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package signals
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
@@ -28,11 +28,11 @@ func SignalContext(parentCtx context.Context) context.Context {
|
||||
signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
log.Println("Received interrupt signal. Shutting down…")
|
||||
slog.Info("Received interrupt signal. Shutting down…")
|
||||
cancel()
|
||||
|
||||
<-sigCh
|
||||
log.Println("Received a second interrupt signal. Forcing an immediate shutdown.")
|
||||
slog.Warn("Received a second interrupt signal. Forcing an immediate shutdown.")
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
|
||||
85
backend/internal/utils/slogfanout.go
Normal file
85
backend/internal/utils/slogfanout.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// This file contains code adapted from https://github.com/samber/slog-multi
|
||||
// Source: https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/pipe.go
|
||||
// Copyright (C) 2023 Samuel Berthe
|
||||
// License: MIT (https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/LICENSE)
|
||||
|
||||
// LogFanoutHandler is a slog.Handler that sends logs to multiple destinations
|
||||
type LogFanoutHandler []slog.Handler
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
for i := range h {
|
||||
if h[i].Enabled(ctx, l) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
errs := make([]error, 0)
|
||||
for i := range h {
|
||||
if h[i].Enabled(ctx, r.Level) {
|
||||
err := try(func() error {
|
||||
return h[i].Handle(ctx, r.Clone())
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
res := make(LogFanoutHandler, len(h))
|
||||
for i, v := range h {
|
||||
res[i] = v.WithAttrs(slices.Clone(attrs))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) WithGroup(name string) slog.Handler {
|
||||
// https://cs.opensource.google/go/x/exp/+/46b07846:slog/handler.go;l=247
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
|
||||
res := make(LogFanoutHandler, len(h))
|
||||
for i, v := range h {
|
||||
res[i] = v.WithGroup(name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func try(callback func() error) (err error) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r != nil {
|
||||
if e, ok := r.(error); ok {
|
||||
err = e
|
||||
} else {
|
||||
err = fmt.Errorf("unexpected error: %+v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = callback()
|
||||
|
||||
return
|
||||
}
|
||||
51
backend/internal/utils/sqlite/sqlite_util.go
Normal file
51
backend/internal/utils/sqlite/sqlite_util.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package sqlite
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
sqlitelib "github.com/glebarez/go-sqlite"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
func RegisterSqliteFunctions() {
|
||||
// Register the `normalize(text, form)` function, which performs Unicode normalization on the text
|
||||
// This is currently only used in migration functions
|
||||
sqlitelib.MustRegisterDeterministicScalarFunction("normalize", 2, func(ctx *sqlitelib.FunctionContext, args []driver.Value) (driver.Value, error) {
|
||||
if len(args) != 2 {
|
||||
return nil, errors.New("normalize requires 2 arguments")
|
||||
}
|
||||
|
||||
arg0, ok := args[0].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("first argument for normalize is not a string: %T", args[0])
|
||||
}
|
||||
|
||||
arg1, ok := args[1].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("second argument for normalize is not a string: %T", args[1])
|
||||
}
|
||||
|
||||
var form norm.Form
|
||||
switch strings.ToLower(arg1) {
|
||||
case "nfc":
|
||||
form = norm.NFC
|
||||
case "nfd":
|
||||
form = norm.NFD
|
||||
case "nfkc":
|
||||
form = norm.NFKC
|
||||
case "nfkd":
|
||||
form = norm.NFKD
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported form: %s", arg1)
|
||||
}
|
||||
|
||||
if len(arg0) == 0 {
|
||||
return arg0, nil
|
||||
}
|
||||
|
||||
return form.String(arg0), nil
|
||||
})
|
||||
}
|
||||
@@ -17,9 +17,14 @@ import (
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func init() {
|
||||
sqliteutil.RegisterSqliteFunctions()
|
||||
}
|
||||
|
||||
// NewDatabaseForTest returns a new instance of GORM connected to an in-memory SQLite database.
|
||||
// Each database connection is unique for the test.
|
||||
// All migrations are automatically performed.
|
||||
@@ -50,7 +55,9 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
|
||||
// Perform migrations with the embedded migrations
|
||||
sqlDB, err := db.DB()
|
||||
require.NoError(t, err, "Failed to get sql.DB")
|
||||
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{})
|
||||
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{
|
||||
NoTxWrap: true,
|
||||
})
|
||||
require.NoError(t, err, "Failed to create migration driver")
|
||||
source, err := iofs.New(resources.FS, "migrations/sqlite")
|
||||
require.NoError(t, err, "Failed to create embedded migration source")
|
||||
@@ -58,6 +65,8 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
|
||||
require.NoError(t, err, "Failed to create migration instance")
|
||||
err = m.Up()
|
||||
require.NoError(t, err, "Failed to perform migrations")
|
||||
_, err = sqlDB.Exec("PRAGMA foreign_keys = OFF;")
|
||||
require.NoError(t, err, "Failed to disable foreign keys")
|
||||
|
||||
return db
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,17 +1,3 @@
|
||||
{{ define "base" }}
|
||||
<div class="header">
|
||||
<div class="logo">
|
||||
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
|
||||
<h1>{{ .AppName }}</h1>
|
||||
</div>
|
||||
<div class="warning">Warning</div>
|
||||
</div>
|
||||
<div class="content">
|
||||
<h2>API Key Expiring Soon</h2>
|
||||
<p>
|
||||
Hello {{ .Data.Name }},<br/><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>.<br/><br/>
|
||||
Please generate a new API key if you need continued access.
|
||||
</p>
|
||||
</div>
|
||||
{{ end }}
|
||||
{{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">
|
||||
<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}}
|
||||
@@ -1,10 +1,12 @@
|
||||
{{ define "base" -}}
|
||||
API Key Expiring Soon
|
||||
====================
|
||||
{{define "root"}}{{.AppName}}
|
||||
|
||||
Hello {{ .Data.Name }},
|
||||
|
||||
This is a reminder that your API key "{{ .Data.ApiKeyName }}" will expire on {{ .Data.ExpiresAt.Format "2006-01-02 15:04:05 MST" }}.
|
||||
API KEY EXPIRING SOON
|
||||
|
||||
Please generate a new API key if you need continued access.
|
||||
{{ end -}}
|
||||
Warning
|
||||
|
||||
Hello {{.Data.Name}},
|
||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on
|
||||
{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||
|
||||
Please generate a new API key if you need continued access.{{end}}
|
||||
@@ -1,14 +0,0 @@
|
||||
{{ define "root" }}
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
{{ template "style" . }}
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
{{ template "base" . }}
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
{{ end }}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user