mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-29 10:46:38 +00:00
Compare commits
267 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f9576479a8 | ||
|
|
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 | ||
|
|
ae737dddaa | ||
|
|
f565c702e5 | ||
|
|
f945b44bc9 | ||
|
|
857b9cc864 | ||
|
|
bf042563e9 | ||
|
|
49f1ab2f75 | ||
|
|
e46f60ac8d | ||
|
|
5c9e504291 | ||
|
|
7fe83f8087 | ||
|
|
43f0114c57 | ||
|
|
1a41b05f60 | ||
|
|
81315790a8 | ||
|
|
8c8fc2304d | ||
|
|
15ece0ab30 | ||
|
|
5550729120 | ||
|
|
9872608d61 | ||
|
|
be52660227 | ||
|
|
237342e876 | ||
|
|
cfbfbc9753 | ||
|
|
aefb308536 | ||
|
|
031181ad2a | ||
|
|
dbf3da41f3 | ||
|
|
3a2902789e | ||
|
|
459a4fd727 | ||
|
|
2ecc1abbad | ||
|
|
92c57ada1a | ||
|
|
fceb6fa7b4 | ||
|
|
c290c027fb | ||
|
|
ca205a8c73 | ||
|
|
968cf0b307 | ||
|
|
fd8bee94a4 | ||
|
|
41ac1be082 | ||
|
|
dd9b1d26ea | ||
|
|
4b829757b2 | ||
|
|
b5b01cb6dd | ||
|
|
287314f016 | ||
|
|
73e7e0b1c5 | ||
|
|
d070b9a778 | ||
|
|
d976bf5965 | ||
|
|
052ac008c3 | ||
|
|
57a2b2bc83 | ||
|
|
043f82ad79 | ||
|
|
ba61cdba4e | ||
|
|
dcd1ae96e0 | ||
|
|
1fdb058386 | ||
|
|
29cb5513a0 | ||
|
|
6db57d9f27 | ||
|
|
1a77bd9914 | ||
|
|
350335711b | ||
|
|
988c425150 | ||
|
|
23827ba1d1 | ||
|
|
7d36bda769 | ||
|
|
8c559ea067 | ||
|
|
88832d4bc9 | ||
|
|
f5cece3b0e | ||
|
|
d5485238b8 | ||
|
|
ac5a121f66 | ||
|
|
481df3bcb9 | ||
|
|
7677a3de2c | ||
|
|
1f65c01b04 | ||
|
|
d5928f6fea | ||
|
|
bef77ac8dc | ||
|
|
c8eb034c49 | ||
|
|
c77167df46 | ||
|
|
3717a663d9 | ||
|
|
5814549cbe | ||
|
|
2e5d268798 | ||
|
|
4ed312251e | ||
|
|
946c534b08 | ||
|
|
883877adec | ||
|
|
215531d65c | ||
|
|
c0f055c3c0 | ||
|
|
d77044882d | ||
|
|
d6795300b1 | ||
|
|
fd3c76ffa3 | ||
|
|
698bc3a35a | ||
|
|
1bcb50edc3 | ||
|
|
9700afb9cb | ||
|
|
9ce82fb205 | ||
|
|
2935236ace | ||
|
|
c821b675b8 | ||
|
|
a09d529027 | ||
|
|
b62b61fb01 | ||
|
|
df5c1ed1f8 | ||
|
|
f4af35f86b | ||
|
|
657a51f7ed | ||
|
|
575b2f71e9 | ||
|
|
97f7326da4 | ||
|
|
242d87a54b | ||
|
|
c111b79147 | ||
|
|
61bf14225b | ||
|
|
c1e98411b6 | ||
|
|
b25e95fc4a | ||
|
|
3cc82d8522 | ||
|
|
ea4e48680c | ||
|
|
f403eed12c | ||
|
|
388a874922 | ||
|
|
9a4aab465a | ||
|
|
a052cd6619 | ||
|
|
31a803b243 | ||
|
|
1d2e41c04e | ||
|
|
b650d6d423 | ||
|
|
156aad3057 | ||
|
|
05bfe00924 | ||
|
|
035b2c022b | ||
|
|
61b62d4612 | ||
|
|
dc5d7bb2f3 | ||
|
|
5e9096e328 | ||
|
|
34b4ba514f | ||
|
|
d217083059 | ||
|
|
bdcef60cab | ||
|
|
14f59ce3f3 | ||
|
|
31ad904367 | ||
|
|
04fcf1110e | ||
|
|
eb9b6433ae | ||
|
|
b9489b5e9a | ||
|
|
bd1c69b7b7 | ||
|
|
23dc235bac | ||
|
|
2440379cd1 | ||
|
|
6c00aaa3ef | ||
|
|
00259f8819 | ||
|
|
decf8ec70b | ||
|
|
c24a5546a5 | ||
|
|
312421d777 | ||
|
|
c42a29a66c | ||
|
|
afc317adf7 | ||
|
|
256f74d0a3 | ||
|
|
20d3f780a2 | ||
|
|
6d6dc6646a | ||
|
|
3d402fc0ca | ||
|
|
b874681824 | ||
|
|
97cbdfb1ef |
@@ -1,4 +1,4 @@
|
|||||||
node_modules
|
**/node_modules
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
|
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
|
||||||
APP_URL=http://localhost:1411
|
APP_URL=https://your-pocket-id-domain.com
|
||||||
TRUST_PROXY=false
|
TRUST_PROXY=false
|
||||||
MAXMIND_LICENSE_KEY=
|
MAXMIND_LICENSE_KEY=
|
||||||
PUID=1000
|
PUID=1000
|
||||||
|
|||||||
1
.github/CODEOWNERS
vendored
Normal file
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @pocket-id/maintainers
|
||||||
24
.github/ISSUE_TEMPLATE/bug.yml
vendored
24
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: "🐛 Bug Report"
|
name: "🐛 Bug Report"
|
||||||
description: "Report something that is not working as expected"
|
description: "Report something that is not working as expected"
|
||||||
title: "🐛 Bug Report: "
|
title: "🐛 Bug Report: "
|
||||||
labels: [bug]
|
type: 'Bug'
|
||||||
body:
|
body:
|
||||||
- type: markdown
|
- type: markdown
|
||||||
attributes:
|
attributes:
|
||||||
@@ -36,13 +36,29 @@ body:
|
|||||||
value: |
|
value: |
|
||||||
### Additional Information
|
### Additional Information
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: extra-information
|
id: version
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
attributes:
|
attributes:
|
||||||
label: "Version and Environment"
|
label: "Pocket ID Version"
|
||||||
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
description: "Please specify the version of Pocket ID."
|
||||||
placeholder: "e.g., v0.24.1"
|
placeholder: "e.g., v0.24.1"
|
||||||
|
- type: textarea
|
||||||
|
id: database
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "Database"
|
||||||
|
description: "Please specify the database in use: SQLite or Postgres (including version)."
|
||||||
|
placeholder: "e.g., SQLite or Postgres 17"
|
||||||
|
- type: textarea
|
||||||
|
id: environment
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
attributes:
|
||||||
|
label: "OS and Environment"
|
||||||
|
description: "Please include the OS, whether you're using containers (Docker, Podman, etc) along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||||
|
placeholder: "e.g., Docker on Ubuntu 24.04, served using Traefik"
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: log-files
|
id: log-files
|
||||||
validations:
|
validations:
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: 🚀 Feature
|
name: 🚀 Feature
|
||||||
description: "Submit a proposal for a new feature"
|
description: "Submit a proposal for a new feature"
|
||||||
title: "🚀 Feature: "
|
title: "🚀 Feature: "
|
||||||
labels: [feature]
|
type: 'Feature'
|
||||||
body:
|
body:
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: feature-description
|
id: feature-description
|
||||||
|
|||||||
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
@@ -1,7 +1,7 @@
|
|||||||
name: "🌐 Language request"
|
name: "🌐 Language request"
|
||||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||||
title: "🌐 Language Request: <language name in english>"
|
title: "🌐 Language Request: <language name in english>"
|
||||||
labels: [language-request]
|
type: 'Language Request'
|
||||||
body:
|
body:
|
||||||
- type: input
|
- type: input
|
||||||
id: language-name-native
|
id: language-name-native
|
||||||
|
|||||||
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
|
go-version-file: backend/go.mod
|
||||||
|
|
||||||
- name: Run Golangci-lint
|
- name: Run Golangci-lint
|
||||||
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
|
uses: golangci/golangci-lint-action@v8.0.0
|
||||||
with:
|
with:
|
||||||
version: v2.0.2
|
version: v2.4.0
|
||||||
args: --build-tags=exclude_frontend
|
args: --build-tags=exclude_frontend
|
||||||
working-directory: backend
|
working-directory: backend
|
||||||
only-new-issues: ${{ github.event_name == 'pull_request' }}
|
only-new-issues: ${{ github.event_name == 'pull_request' }}
|
||||||
|
|||||||
98
.github/workflows/build-next.yml
vendored
Normal file
98
.github/workflows/build-next.yml
vendored
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
name: Build Next Image
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: build-next-image
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-next:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
id-token: write
|
||||||
|
attestations: write
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: 'pnpm'
|
||||||
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Setup Go
|
||||||
|
uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: 'backend/go.mod'
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Set DOCKER_IMAGE_NAME
|
||||||
|
run: |
|
||||||
|
# Lowercase REPO_OWNER which is required for containers
|
||||||
|
REPO_OWNER=${{ github.repository_owner }}
|
||||||
|
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
||||||
|
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
||||||
|
|
||||||
|
- name: Login to GitHub Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ghcr.io
|
||||||
|
username: ${{ github.repository_owner }}
|
||||||
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Build frontend
|
||||||
|
working-directory: frontend
|
||||||
|
run: pnpm run build
|
||||||
|
|
||||||
|
- name: Build binaries
|
||||||
|
run: sh scripts/development/build-binaries.sh --docker-only
|
||||||
|
|
||||||
|
- name: Build and push container image
|
||||||
|
id: build-push-image
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.DOCKER_IMAGE_NAME }}:next
|
||||||
|
file: Dockerfile-prebuilt
|
||||||
|
- name: Build and push container image (distroless)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
id: container-build-push-distroless
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ env.DOCKER_IMAGE_NAME }}:next-distroless
|
||||||
|
file: Dockerfile-distroless
|
||||||
|
- name: Container image attestation
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||||
|
subject-digest: ${{ steps.build-push-image.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
|
- name: Container image attestation (distroless)
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||||
|
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
185
.github/workflows/e2e-tests.yml
vendored
185
.github/workflows/e2e-tests.yml
vendored
@@ -3,15 +3,15 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "docs/**"
|
- 'docs/**'
|
||||||
- "**.md"
|
- '**.md'
|
||||||
- ".github/**"
|
- '.github/**'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths-ignore:
|
paths-ignore:
|
||||||
- "docs/**"
|
- 'docs/**'
|
||||||
- "**.md"
|
- '**.md'
|
||||||
- ".github/**"
|
- '.github/**'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -45,39 +45,52 @@ jobs:
|
|||||||
path: /tmp/docker-image.tar
|
path: /tmp/docker-image.tar
|
||||||
retention-days: 1
|
retention-days: 1
|
||||||
|
|
||||||
test-sqlite:
|
test:
|
||||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
actions: write
|
actions: write
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: build
|
needs: build
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
db: [sqlite, postgres]
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- uses: actions/setup-node@v4
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "npm"
|
cache: 'pnpm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
- name: Cache Playwright Browsers
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
id: playwright-cache
|
id: playwright-cache
|
||||||
with:
|
with:
|
||||||
path: ~/.cache/ms-playwright
|
path: ~/.cache/ms-playwright
|
||||||
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
|
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||||
restore-keys: |
|
- name: Cache PostgreSQL Docker image
|
||||||
${{ runner.os }}-playwright-
|
if: matrix.db == 'postgres'
|
||||||
|
uses: actions/cache@v3
|
||||||
- name: Download Docker image artifact
|
id: postgres-cache
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
with:
|
||||||
name: docker-image
|
path: /tmp/postgres-image.tar
|
||||||
path: /tmp
|
key: postgres-17-${{ runner.os }}
|
||||||
|
|
||||||
- name: Load Docker image
|
- name: Pull and save PostgreSQL image
|
||||||
run: docker load -i /tmp/docker-image.tar
|
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
|
||||||
|
run: |
|
||||||
|
docker pull postgres:17
|
||||||
|
docker save postgres:17 > /tmp/postgres-image.tar
|
||||||
|
|
||||||
|
- name: Load PostgreSQL image from cache
|
||||||
|
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
|
||||||
|
run: docker load < /tmp/postgres-image.tar
|
||||||
- name: Cache LLDAP Docker image
|
- name: Cache LLDAP Docker image
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v3
|
||||||
id: lldap-cache
|
id: lldap-cache
|
||||||
@@ -94,31 +107,45 @@ jobs:
|
|||||||
- name: Load LLDAP image from cache
|
- name: Load LLDAP image from cache
|
||||||
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
||||||
run: docker load < /tmp/lldap-image.tar
|
run: docker load < /tmp/lldap-image.tar
|
||||||
|
- name: Download Docker image artifact
|
||||||
|
uses: actions/download-artifact@v4
|
||||||
|
with:
|
||||||
|
name: docker-image
|
||||||
|
path: /tmp
|
||||||
|
|
||||||
|
- name: Load Docker image
|
||||||
|
run: docker load -i /tmp/docker-image.tar
|
||||||
|
|
||||||
- name: Install test dependencies
|
- name: Install test dependencies
|
||||||
working-directory: ./tests
|
run: pnpm --filter pocket-id-tests install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
- name: Install Playwright Browsers
|
||||||
working-directory: ./tests
|
working-directory: ./tests
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||||
run: npx playwright install --with-deps chromium
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
- name: Run Docker Container (sqlite) with LDAP
|
||||||
- name: Run Docker Container with Sqlite DB and LDAP
|
if: matrix.db == 'sqlite'
|
||||||
working-directory: ./tests/setup
|
working-directory: ./tests/setup
|
||||||
run: |
|
run: |
|
||||||
docker compose up -d
|
docker compose up -d
|
||||||
docker compose logs -f pocket-id &> /tmp/backend.log &
|
docker compose logs -f pocket-id &> /tmp/backend.log &
|
||||||
|
|
||||||
|
- name: Run Docker Container (postgres) with LDAP
|
||||||
|
if: matrix.db == 'postgres'
|
||||||
|
working-directory: ./tests/setup
|
||||||
|
run: |
|
||||||
|
docker compose -f docker-compose-postgres.yml up -d
|
||||||
|
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
|
||||||
|
|
||||||
- name: Run Playwright tests
|
- name: Run Playwright tests
|
||||||
working-directory: ./tests
|
working-directory: ./tests
|
||||||
run: npx playwright test
|
run: pnpm exec playwright test
|
||||||
|
|
||||||
- name: Upload Test Report
|
- name: Upload Test Report
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
with:
|
with:
|
||||||
name: playwright-report-sqlite
|
name: playwright-report-${{ matrix.db }}
|
||||||
path: tests/.report
|
path: tests/.report
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
@@ -127,111 +154,7 @@ jobs:
|
|||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||||
with:
|
with:
|
||||||
name: backend-sqlite
|
name: backend-${{ matrix.db }}
|
||||||
path: /tmp/backend.log
|
|
||||||
include-hidden-files: true
|
|
||||||
retention-days: 15
|
|
||||||
|
|
||||||
test-postgres:
|
|
||||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
actions: write
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
needs: build
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 22
|
|
||||||
cache: "npm"
|
|
||||||
cache-dependency-path: frontend/package-lock.json
|
|
||||||
|
|
||||||
- name: Cache Playwright Browsers
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: playwright-cache
|
|
||||||
with:
|
|
||||||
path: ~/.cache/ms-playwright
|
|
||||||
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-playwright-
|
|
||||||
|
|
||||||
- name: Cache PostgreSQL Docker image
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: postgres-cache
|
|
||||||
with:
|
|
||||||
path: /tmp/postgres-image.tar
|
|
||||||
key: postgres-17-${{ runner.os }}
|
|
||||||
|
|
||||||
- name: Pull and save PostgreSQL image
|
|
||||||
if: steps.postgres-cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
docker pull postgres:17
|
|
||||||
docker save postgres:17 > /tmp/postgres-image.tar
|
|
||||||
|
|
||||||
- name: Load PostgreSQL image from cache
|
|
||||||
if: steps.postgres-cache.outputs.cache-hit == 'true'
|
|
||||||
run: docker load < /tmp/postgres-image.tar
|
|
||||||
|
|
||||||
- name: Cache LLDAP Docker image
|
|
||||||
uses: actions/cache@v3
|
|
||||||
id: lldap-cache
|
|
||||||
with:
|
|
||||||
path: /tmp/lldap-image.tar
|
|
||||||
key: lldap-stable-${{ runner.os }}
|
|
||||||
|
|
||||||
- name: Pull and save LLDAP image
|
|
||||||
if: steps.lldap-cache.outputs.cache-hit != 'true'
|
|
||||||
run: |
|
|
||||||
docker pull nitnelave/lldap:stable
|
|
||||||
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
|
|
||||||
|
|
||||||
- name: Load LLDAP image from cache
|
|
||||||
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
|
||||||
run: docker load < /tmp/lldap-image.tar
|
|
||||||
|
|
||||||
- name: Download Docker image artifact
|
|
||||||
uses: actions/download-artifact@v4
|
|
||||||
with:
|
|
||||||
name: docker-image
|
|
||||||
path: /tmp
|
|
||||||
|
|
||||||
- name: Load Docker image
|
|
||||||
run: docker load -i /tmp/docker-image.tar
|
|
||||||
|
|
||||||
- name: Install test dependencies
|
|
||||||
working-directory: ./tests
|
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Install Playwright Browsers
|
|
||||||
working-directory: ./tests
|
|
||||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
|
||||||
run: npx playwright install --with-deps chromium
|
|
||||||
|
|
||||||
- name: Run Docker Container with Postgres DB and LDAP
|
|
||||||
working-directory: ./tests/setup
|
|
||||||
run: |
|
|
||||||
docker compose -f docker-compose-postgres.yml up -d
|
|
||||||
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
|
|
||||||
|
|
||||||
- name: Run Playwright tests
|
|
||||||
working-directory: ./tests
|
|
||||||
run: npx playwright test
|
|
||||||
|
|
||||||
- name: Upload Test Report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
|
||||||
with:
|
|
||||||
name: playwright-report-postgres
|
|
||||||
path: frontend/tests/.report
|
|
||||||
include-hidden-files: true
|
|
||||||
retention-days: 15
|
|
||||||
|
|
||||||
- name: Upload Backend Test Report
|
|
||||||
uses: actions/upload-artifact@v4
|
|
||||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
|
||||||
with:
|
|
||||||
name: backend-postgres
|
|
||||||
path: /tmp/backend.log
|
path: /tmp/backend.log
|
||||||
include-hidden-files: true
|
include-hidden-files: true
|
||||||
retention-days: 15
|
retention-days: 15
|
||||||
|
|||||||
57
.github/workflows/release.yml
vendored
57
.github/workflows/release.yml
vendored
@@ -3,7 +3,7 @@ name: Release
|
|||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
tags:
|
tags:
|
||||||
- "v*.*.*"
|
- 'v*.*.*'
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
@@ -16,27 +16,27 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v3
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "npm"
|
cache: 'pnpm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
- uses: actions/setup-go@v5
|
- uses: actions/setup-go@v5
|
||||||
with:
|
with:
|
||||||
go-version-file: "backend/go.mod"
|
go-version-file: 'backend/go.mod'
|
||||||
- name: Set up QEMU
|
- name: Set up QEMU
|
||||||
uses: docker/setup-qemu-action@v3
|
uses: docker/setup-qemu-action@v3
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
- name: Set DOCKER_IMAGE_NAME
|
- name: Set DOCKER_IMAGE_NAME
|
||||||
run: |
|
run: |
|
||||||
# Lowercase REPO_OWNER which is required for containers
|
# Lowercase REPO_OWNER which is required for containers
|
||||||
REPO_OWNER=${{ github.repository_owner }}
|
REPO_OWNER=${{ github.repository_owner }}
|
||||||
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
||||||
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
||||||
|
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3
|
uses: docker/login-action@v3
|
||||||
with:
|
with:
|
||||||
@@ -53,17 +53,25 @@ jobs:
|
|||||||
type=semver,pattern={{version}},prefix=v
|
type=semver,pattern={{version}},prefix=v
|
||||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
type=semver,pattern={{major}},prefix=v
|
type=semver,pattern={{major}},prefix=v
|
||||||
|
- name: Docker metadata (distroless)
|
||||||
|
id: meta-distroless
|
||||||
|
uses: docker/metadata-action@v5
|
||||||
|
with:
|
||||||
|
images: |
|
||||||
|
${{ env.DOCKER_IMAGE_NAME }}
|
||||||
|
flavor: |
|
||||||
|
suffix=-distroless,onlatest=true
|
||||||
|
tags: |
|
||||||
|
type=semver,pattern={{version}},prefix=v
|
||||||
|
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||||
|
type=semver,pattern={{major}},prefix=v
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
working-directory: frontend
|
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
- name: Build frontend
|
- name: Build frontend
|
||||||
working-directory: frontend
|
run: pnpm --filter pocket-id-frontend build
|
||||||
run: npm run build
|
|
||||||
|
|
||||||
- name: Build binaries
|
- name: Build binaries
|
||||||
run: sh scripts/development/build-binaries.sh
|
run: sh scripts/development/build-binaries.sh
|
||||||
|
|
||||||
- name: Build and push container image
|
- name: Build and push container image
|
||||||
uses: docker/build-push-action@v6
|
uses: docker/build-push-action@v6
|
||||||
id: container-build-push
|
id: container-build-push
|
||||||
@@ -74,19 +82,32 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
file: Dockerfile-prebuilt
|
file: Dockerfile-prebuilt
|
||||||
|
- name: Build and push container image (distroless)
|
||||||
|
uses: docker/build-push-action@v6
|
||||||
|
id: container-build-push-distroless
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.meta-distroless.outputs.tags }}
|
||||||
|
labels: ${{ steps.meta-distroless.outputs.labels }}
|
||||||
|
file: Dockerfile-distroless
|
||||||
- name: Binary attestation
|
- name: Binary attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-path: "backend/.bin/pocket-id-**"
|
subject-path: 'backend/.bin/pocket-id-**'
|
||||||
|
|
||||||
- name: Container image attestation
|
- name: Container image attestation
|
||||||
uses: actions/attest-build-provenance@v2
|
uses: actions/attest-build-provenance@v2
|
||||||
with:
|
with:
|
||||||
subject-name: "${{ env.DOCKER_IMAGE_NAME }}"
|
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||||
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
||||||
push-to-registry: true
|
push-to-registry: true
|
||||||
|
- name: Container image attestation (distroless)
|
||||||
|
uses: actions/attest-build-provenance@v2
|
||||||
|
with:
|
||||||
|
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||||
|
subject-digest: ${{ steps.container-build-push-distroless.outputs.digest }}
|
||||||
|
push-to-registry: true
|
||||||
- name: Upload binaries to release
|
- name: Upload binaries to release
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
@@ -101,6 +122,6 @@ jobs:
|
|||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v3
|
uses: actions/checkout@v4
|
||||||
- name: Mark release as published
|
- name: Mark release as published
|
||||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||||
|
|||||||
38
.github/workflows/svelte-check.yml
vendored
38
.github/workflows/svelte-check.yml
vendored
@@ -4,21 +4,21 @@ on:
|
|||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "frontend/src/**"
|
- 'frontend/src/**'
|
||||||
- ".github/svelte-check-matcher.json"
|
- '.github/svelte-check-matcher.json'
|
||||||
- "frontend/package.json"
|
- 'frontend/package.json'
|
||||||
- "frontend/package-lock.json"
|
- 'frontend/package-lock.json'
|
||||||
- "frontend/tsconfig.json"
|
- 'frontend/tsconfig.json'
|
||||||
- "frontend/svelte.config.js"
|
- 'frontend/svelte.config.js'
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
paths:
|
paths:
|
||||||
- "frontend/src/**"
|
- 'frontend/src/**'
|
||||||
- ".github/svelte-check-matcher.json"
|
- '.github/svelte-check-matcher.json'
|
||||||
- "frontend/package.json"
|
- 'frontend/package.json'
|
||||||
- "frontend/package-lock.json"
|
- 'frontend/package-lock.json'
|
||||||
- "frontend/tsconfig.json"
|
- 'frontend/tsconfig.json'
|
||||||
- "frontend/svelte.config.js"
|
- 'frontend/svelte.config.js'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
@@ -36,24 +36,26 @@ jobs:
|
|||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 22
|
node-version: 22
|
||||||
cache: "npm"
|
cache: 'pnpm'
|
||||||
cache-dependency-path: frontend/package-lock.json
|
cache-dependency-path: pnpm-lock.yaml
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
working-directory: frontend
|
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||||
run: npm ci
|
|
||||||
|
|
||||||
- name: Build Pocket ID Frontend
|
- name: Build Pocket ID Frontend
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run build
|
run: pnpm --filter pocket-id-frontend build
|
||||||
|
|
||||||
- name: Add svelte-check problem matcher
|
- name: Add svelte-check problem matcher
|
||||||
run: echo "::add-matcher::.github/svelte-check-matcher.json"
|
run: echo "::add-matcher::.github/svelte-check-matcher.json"
|
||||||
|
|
||||||
- name: Run svelte-check
|
- name: Run svelte-check
|
||||||
working-directory: frontend
|
working-directory: frontend
|
||||||
run: npm run check
|
run: pnpm --filter pocket-id-frontend check
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -10,6 +10,7 @@ node_modules
|
|||||||
/frontend/build
|
/frontend/build
|
||||||
/backend/bin
|
/backend/bin
|
||||||
pocket-id
|
pocket-id
|
||||||
|
/tests/test-results/*.json
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
223
CHANGELOG.md
223
CHANGELOG.md
@@ -1,3 +1,226 @@
|
|||||||
|
## [](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)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add "key-rotate" command ([#709](https://github.com/pocket-id/pocket-id/issues/709)) ([8c8fc23](https://github.com/pocket-id/pocket-id/commit/8c8fc2304d8f33c1fea54b1138b109f282e78b8b))
|
||||||
|
* add support for OAuth 2.0 Authorization Server Issuer Identification ([bf04256](https://github.com/pocket-id/pocket-id/commit/bf042563e997d57bb087705a5789fd72ffbed467))
|
||||||
|
* distroless container additional variant + healthcheck command ([#716](https://github.com/pocket-id/pocket-id/issues/716)) ([1a41b05](https://github.com/pocket-id/pocket-id/commit/1a41b05f60d487fff78703bec1d4e832f96fd071))
|
||||||
|
* encrypt private keys saved on disk and in database ([#682](https://github.com/pocket-id/pocket-id/issues/682)) ([5550729](https://github.com/pocket-id/pocket-id/commit/5550729120ac9f5e9361c7f9cf25b9075a33a94a))
|
||||||
|
* enhance language selection message and add translation contribution link ([be52660](https://github.com/pocket-id/pocket-id/commit/be526602273c1689cb4057ca96d4214e7f817d1d))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* actually fix linter issues ([#720](https://github.com/pocket-id/pocket-id/issues/720)) ([7fe83f8](https://github.com/pocket-id/pocket-id/commit/7fe83f8087f033f957bb6e0eee5e0c159417e1cd))
|
||||||
|
* add missing error check in initial user setup ([fceb6fa](https://github.com/pocket-id/pocket-id/commit/fceb6fa7b4701a3645c4c2353bcd108b15d69ded))
|
||||||
|
* allow profile picture update even if "allow own account edit" enabled ([9872608](https://github.com/pocket-id/pocket-id/commit/9872608d61a486f7b775f314d9392e0620bcd891))
|
||||||
|
* app config forms not updating with latest values ([#696](https://github.com/pocket-id/pocket-id/issues/696)) ([92c57ad](https://github.com/pocket-id/pocket-id/commit/92c57ada1a11f76963e36ca0a81bca8f52dbc84e))
|
||||||
|
* auth fails when client IP is empty on Postgres ([#695](https://github.com/pocket-id/pocket-id/issues/695)) ([031181a](https://github.com/pocket-id/pocket-id/commit/031181ad2ae8fae94cc5793dd1c614e79476a766))
|
||||||
|
* custom claims input suggestions flickering ([49f1ab2](https://github.com/pocket-id/pocket-id/commit/49f1ab2f75df97d551fff5acbadcd55df74af617))
|
||||||
|
* keep sidebar in settings sticky ([e46f60a](https://github.com/pocket-id/pocket-id/commit/e46f60ac8d6944bcea54d0708af1950d98f66c3c))
|
||||||
|
* linter issues ([#719](https://github.com/pocket-id/pocket-id/issues/719)) ([43f0114](https://github.com/pocket-id/pocket-id/commit/43f0114c579f7b5b32b372e09f46bcb2a9d7796e))
|
||||||
|
* show friendly name in user group selection ([5c9e504](https://github.com/pocket-id/pocket-id/commit/5c9e504291b3bffe947bcbe907701806e301d1fe))
|
||||||
|
* support non UTF-8 LDAP IDs ([#714](https://github.com/pocket-id/pocket-id/issues/714)) ([8131579](https://github.com/pocket-id/pocket-id/commit/81315790a8aa601a2565a1b54807df1e68f06dc5))
|
||||||
|
* token introspection authentication not handled correctly ([#704](https://github.com/pocket-id/pocket-id/issues/704)) ([aefb308](https://github.com/pocket-id/pocket-id/commit/aefb30853677baf7ed29ac8b539e1aadf56e14a4))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.1...v) (2025-06-27)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* improve initial admin creation workflow ([287314f](https://github.com/pocket-id/pocket-id/commit/287314f01644e42ddb2ce1b1115bd14f2f0c1768))
|
||||||
|
* redact sensitive app config variables if set with env variable ([ba61cdb](https://github.com/pocket-id/pocket-id/commit/ba61cdba4eb3d5659f3ae6b6c21249985c0aa630))
|
||||||
|
* self-service user signup ([#672](https://github.com/pocket-id/pocket-id/issues/672)) ([dcd1ae9](https://github.com/pocket-id/pocket-id/commit/dcd1ae96e048115be34b0cce275054e990462ebf))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* double double full stops for certain error messages ([d070b9a](https://github.com/pocket-id/pocket-id/commit/d070b9a778d7d1a51f2fa62d003f2331a96d6c91))
|
||||||
|
* error page flickering after sign out ([1a77bd9](https://github.com/pocket-id/pocket-id/commit/1a77bd9914ea01e445ff3d6e116c9ed3bcfbf153))
|
||||||
|
* improve accent color picker disabled state ([d976bf5](https://github.com/pocket-id/pocket-id/commit/d976bf5965eda10e3ecb71821c23e93e5d712a02))
|
||||||
|
* less noisy logging for certain GET requests ([#681](https://github.com/pocket-id/pocket-id/issues/681)) ([043f82a](https://github.com/pocket-id/pocket-id/commit/043f82ad794eb64a5550d8b80703114a055701d9))
|
||||||
|
* margin of user sign up description ([052ac00](https://github.com/pocket-id/pocket-id/commit/052ac008c3a8c910d1ce79ee99b2b2f75e4090f4))
|
||||||
|
* remove duplicate request logging ([#678](https://github.com/pocket-id/pocket-id/issues/678)) ([988c425](https://github.com/pocket-id/pocket-id/commit/988c425150556b32cff1d341a21fcc9c69d9aaf8))
|
||||||
|
* users can't be updated by admin if self account editing is disabled ([29cb551](https://github.com/pocket-id/pocket-id/commit/29cb5513a03d1a9571969c8a42deec9b2bdee037))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.4.0...v) (2025-06-22)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* app not starting if UI config is disabled and Postgres is used ([7d36bda](https://github.com/pocket-id/pocket-id/commit/7d36bda769e25497dec6b76206a4f7e151b0bd72))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.1...v) (2025-06-19)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* allow setting unix socket mode ([#661](https://github.com/pocket-id/pocket-id/issues/661)) ([7677a3d](https://github.com/pocket-id/pocket-id/commit/7677a3de2c923c11a58bc8c4d1b2121d403a1504))
|
||||||
|
* auto-focus on the login buttons ([#647](https://github.com/pocket-id/pocket-id/issues/647)) ([d679530](https://github.com/pocket-id/pocket-id/commit/d6795300b158b85dd9feadd561b6ecd891f5db0d))
|
||||||
|
* configurable local ipv6 ranges for audit log ([#657](https://github.com/pocket-id/pocket-id/issues/657)) ([d548523](https://github.com/pocket-id/pocket-id/commit/d5485238b8fd4cc566af00eae2b17d69a119f991))
|
||||||
|
* location filter for global audit log ([#662](https://github.com/pocket-id/pocket-id/issues/662)) ([ac5a121](https://github.com/pocket-id/pocket-id/commit/ac5a121f664b8127d0faf30c0f93432f30e7f33a))
|
||||||
|
* ui accent colors ([#643](https://github.com/pocket-id/pocket-id/issues/643)) ([883877a](https://github.com/pocket-id/pocket-id/commit/883877adec6fc3e65bd5a705499449959b894fb5))
|
||||||
|
* use icon instead of text on application image update hover state ([215531d](https://github.com/pocket-id/pocket-id/commit/215531d65c6683609b0b4a5505fdb72696fdb93e))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow images with uppercase file extension ([1bcb50e](https://github.com/pocket-id/pocket-id/commit/1bcb50edc335886dd722a4c69960c48cc3cd1687))
|
||||||
|
* center oidc client images if they are smaller than the box ([946c534](https://github.com/pocket-id/pocket-id/commit/946c534b0877a074a6b658060f9af27e4061397c))
|
||||||
|
* explicitly cache images to prevent unexpected behavior ([2e5d268](https://github.com/pocket-id/pocket-id/commit/2e5d2687982186c12e530492292d49895cb6043a))
|
||||||
|
* reduce duration of animations on login and signin page ([#648](https://github.com/pocket-id/pocket-id/issues/648)) ([d770448](https://github.com/pocket-id/pocket-id/commit/d77044882d5a41da22df1c0099c1eb1f20bcbc5b))
|
||||||
|
* use inline style for dynamic background image URL instead of Tailwind class ([bef77ac](https://github.com/pocket-id/pocket-id/commit/bef77ac8dca2b98b6732677aaafbc28f79d00487))
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.3.0...v) (2025-06-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* change timestamp of `client_credentials.sql` migration ([2935236](https://github.com/pocket-id/pocket-id/commit/2935236acee9c78c2fe6787ec8b5f53ae0eca047))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.2.0...v) (2025-06-09)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add API endpoint for user authorized clients ([d217083](https://github.com/pocket-id/pocket-id/commit/d217083059120171d5c555b09eefe6ba3c8a8d42))
|
||||||
|
* add unix socket support ([#615](https://github.com/pocket-id/pocket-id/issues/615)) ([035b2c0](https://github.com/pocket-id/pocket-id/commit/035b2c022bfd2b98f13355ec7a126e0f1ab3ebd8))
|
||||||
|
* allow introspection and device code endpoints to use Federated Client Credentials ([#640](https://github.com/pocket-id/pocket-id/issues/640)) ([b62b61f](https://github.com/pocket-id/pocket-id/commit/b62b61fb017dba31a6fc612c138bebf370d3956c))
|
||||||
|
* JWT bearer assertions for client authentication ([#566](https://github.com/pocket-id/pocket-id/issues/566)) ([05bfe00](https://github.com/pocket-id/pocket-id/commit/05bfe0092450c9bc26d03c6a54c21050eef8f63a))
|
||||||
|
* new color theme for the UI ([97f7326](https://github.com/pocket-id/pocket-id/commit/97f7326da40265a954340d519661969530f097a0))
|
||||||
|
* oidc client data preview ([#624](https://github.com/pocket-id/pocket-id/issues/624)) ([c111b79](https://github.com/pocket-id/pocket-id/commit/c111b7914731a3cafeaa55102b515f84a1ad74dc))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* don't load app config and user on every route change ([bdcef60](https://github.com/pocket-id/pocket-id/commit/bdcef60cab6a61e1717661e918c42e3650d23fee))
|
||||||
|
* misleading text for disable animations option ([657a51f](https://github.com/pocket-id/pocket-id/commit/657a51f7ed8a77e8a937971032091058aacfded6))
|
||||||
|
* OIDC client image can't be deleted ([61b62d4](https://github.com/pocket-id/pocket-id/commit/61b62d461200c1359a16c92c9c62530362a4785c))
|
||||||
|
* UI config overridden by env variables don't apply on first start ([5e9096e](https://github.com/pocket-id/pocket-id/commit/5e9096e328741ba2a0e03835927fe62e6aea2a89))
|
||||||
|
* use full width for audit log filters ([575b2f7](https://github.com/pocket-id/pocket-id/commit/575b2f71e9f1ff9c4f6fd411b136676c213b7201))
|
||||||
|
|
||||||
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.1.0...v) (2025-06-03)
|
||||||
|
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* auto detect callback url ([#583](https://github.com/pocket-id/pocket-id/issues/583)) ([20d3f78](https://github.com/pocket-id/pocket-id/commit/20d3f780a2a431d0a48cece0f0764b6e4d53c1b9))
|
||||||
|
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* allow users to update their locale even when own account update disabled ([6c00aaa](https://github.com/pocket-id/pocket-id/commit/6c00aaa3efa75c76d340718698a0f4556e8de268))
|
||||||
|
* clear default app config variables from database ([decf8ec](https://github.com/pocket-id/pocket-id/commit/decf8ec70b5f6a69fe201d6e4ad60ee62e374ad0))
|
||||||
|
* don't use TOFU for logout callback URLs ([#588](https://github.com/pocket-id/pocket-id/issues/588)) ([256f74d](https://github.com/pocket-id/pocket-id/commit/256f74d0a348a835107fd5b17b9d57b1e845029e))
|
||||||
|
* fallback to primary language if no translation available for specific country ([2440379](https://github.com/pocket-id/pocket-id/commit/2440379cd11b4a6da7c52b122ba8f49d7c72ce1d))
|
||||||
|
* improve spacing on auth screens ([04fcf11](https://github.com/pocket-id/pocket-id/commit/04fcf1110e97b42dc5f0c20e169c569075d1e797))
|
||||||
|
* page scrolls up on form submisssion ([31ad904](https://github.com/pocket-id/pocket-id/commit/31ad904367e53dd47a15abcce5402dfe84828a14))
|
||||||
|
* run jobs at interval instead of specific time ([#585](https://github.com/pocket-id/pocket-id/issues/585)) ([6d6dc66](https://github.com/pocket-id/pocket-id/commit/6d6dc6646a39921a604b6c825d3e7e76af6c693b))
|
||||||
|
* show LAN for auditlog location for internal networks ([b874681](https://github.com/pocket-id/pocket-id/commit/b8746818240fde052e6f3b5db5c3355d7bbfcbda))
|
||||||
|
* small fixes in analytics_job ([#582](https://github.com/pocket-id/pocket-id/issues/582)) ([3d402fc](https://github.com/pocket-id/pocket-id/commit/3d402fc0ca30626c95b8f7accc274b9f2ab228b9))
|
||||||
|
* whitelist authorization header for CORS ([b9489b5](https://github.com/pocket-id/pocket-id/commit/b9489b5e9a32a2a3f54d48705e731a7bcf188d20))
|
||||||
|
|
||||||
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
|
## [](https://github.com/pocket-id/pocket-id/compare/v1.0.0...v) (2025-05-28)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ Before you submit the pull request for review please ensure that
|
|||||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||||
|
|
||||||
- Your pull request has a detailed description
|
- Your pull request has a detailed description
|
||||||
- You run `npm run format` to format the code
|
- You run `pnpm format` to format the code
|
||||||
|
|
||||||
## Development Environment
|
## Development Environment
|
||||||
|
|
||||||
@@ -52,7 +52,7 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
|
|||||||
If you don't use Dev Containers, you need to install the following tools manually:
|
If you don't use Dev Containers, you need to install the following tools manually:
|
||||||
|
|
||||||
- [Node.js](https://nodejs.org/en/download/) >= 22
|
- [Node.js](https://nodejs.org/en/download/) >= 22
|
||||||
- [Go](https://golang.org/doc/install) >= 1.24
|
- [Go](https://golang.org/doc/install) >= 1.25
|
||||||
- [Git](https://git-scm.com/downloads)
|
- [Git](https://git-scm.com/downloads)
|
||||||
|
|
||||||
### 2. Setup
|
### 2. Setup
|
||||||
@@ -69,10 +69,10 @@ The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set
|
|||||||
|
|
||||||
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
|
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
|
||||||
|
|
||||||
1. Open the `frontend` folder
|
1. Open the `pocket-id` project folder
|
||||||
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
|
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
|
||||||
3. Install the dependencies with `npm install`
|
3. Install the dependencies with `pnpm install`
|
||||||
4. Start the frontend with `npm run dev`
|
4. Start the frontend with `pnpm dev`
|
||||||
|
|
||||||
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
|
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
|
||||||
|
|
||||||
@@ -84,11 +84,13 @@ If you are contributing to a new feature please ensure that you add tests for it
|
|||||||
|
|
||||||
The tests can be run like this:
|
The tests can be run like this:
|
||||||
|
|
||||||
1. Visit the setup folder by running `cd tests/setup`
|
1. Install the dependencies from the root of the project `pnpm install`
|
||||||
|
|
||||||
2. Start the test environment by running `docker compose up -d --build`
|
2. Visit the setup folder by running `cd tests/setup`
|
||||||
|
|
||||||
3. Go back to the test folder by running `cd ..`
|
3. Start the test environment by running `docker compose up -d --build`
|
||||||
4. Run the tests with `npx playwright test`
|
|
||||||
|
4. Go back to the test folder by running `cd ..`
|
||||||
|
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
|
||||||
|
|
||||||
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
|
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -5,21 +5,27 @@ ARG BUILD_TAGS=""
|
|||||||
|
|
||||||
# Stage 1: Build Frontend
|
# Stage 1: Build Frontend
|
||||||
FROM node:22-alpine AS frontend-builder
|
FROM node:22-alpine AS frontend-builder
|
||||||
|
RUN corepack enable
|
||||||
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY ./frontend/package*.json ./
|
|
||||||
RUN npm ci
|
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||||
COPY ./frontend ./
|
COPY frontend/package.json ./frontend/
|
||||||
RUN BUILD_OUTPUT_PATH=dist npm run build
|
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY ./frontend ./frontend/
|
||||||
|
|
||||||
|
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
|
||||||
|
|
||||||
# Stage 2: Build Backend
|
# Stage 2: Build Backend
|
||||||
FROM golang:1.24-alpine AS backend-builder
|
FROM golang:1.25-alpine AS backend-builder
|
||||||
ARG BUILD_TAGS
|
ARG BUILD_TAGS
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY ./backend/go.mod ./backend/go.sum ./
|
COPY ./backend/go.mod ./backend/go.sum ./
|
||||||
RUN go mod download
|
RUN go mod download
|
||||||
|
|
||||||
COPY ./backend ./
|
COPY ./backend ./
|
||||||
COPY --from=frontend-builder /build/dist ./frontend/dist
|
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
|
||||||
COPY .version .version
|
COPY .version .version
|
||||||
|
|
||||||
WORKDIR /build/cmd
|
WORKDIR /build/cmd
|
||||||
@@ -30,7 +36,7 @@ RUN VERSION=$(cat /build/.version) \
|
|||||||
-tags "${BUILD_TAGS}" \
|
-tags "${BUILD_TAGS}" \
|
||||||
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
|
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
|
||||||
-trimpath \
|
-trimpath \
|
||||||
-o /build/pocket-id-backend \
|
-o /build/pocket-id \
|
||||||
.
|
.
|
||||||
|
|
||||||
# Stage 3: Production Image
|
# Stage 3: Production Image
|
||||||
@@ -39,7 +45,7 @@ WORKDIR /app
|
|||||||
|
|
||||||
RUN apk add --no-cache curl su-exec
|
RUN apk add --no-cache curl su-exec
|
||||||
|
|
||||||
COPY --from=backend-builder /build/pocket-id-backend /app/pocket-id
|
COPY --from=backend-builder /build/pocket-id /app/pocket-id
|
||||||
COPY ./scripts/docker /app/docker
|
COPY ./scripts/docker /app/docker
|
||||||
|
|
||||||
RUN chmod +x /app/pocket-id && \
|
RUN chmod +x /app/pocket-id && \
|
||||||
@@ -48,5 +54,7 @@ RUN chmod +x /app/pocket-id && \
|
|||||||
EXPOSE 1411
|
EXPOSE 1411
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
|
||||||
|
|
||||||
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
|
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
|
||||||
CMD ["/app/pocket-id"]
|
CMD ["/app/pocket-id"]
|
||||||
|
|||||||
18
Dockerfile-distroless
Normal file
18
Dockerfile-distroless
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# This Dockerfile embeds a pre-built binary for the given Linux architecture
|
||||||
|
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12:nonroot
|
||||||
|
|
||||||
|
# TARGETARCH can be "amd64" or "arm64"
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
|
||||||
|
|
||||||
|
EXPOSE 1411
|
||||||
|
ENV APP_ENV=production
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
|
||||||
|
|
||||||
|
CMD ["/app/pocket-id"]
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
# This Dockerfile embeds a pre-built binary for the given Linux architecture
|
# This Dockerfile embeds a pre-built binary for the given Linux architecture
|
||||||
# Binaries must be built using ./scripts/development/build-binaries.sh first
|
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
|
||||||
|
|
||||||
FROM alpine
|
FROM alpine
|
||||||
|
|
||||||
@@ -16,5 +16,7 @@ COPY ./scripts/docker /app/docker
|
|||||||
EXPOSE 1411
|
EXPOSE 1411
|
||||||
ENV APP_ENV=production
|
ENV APP_ENV=production
|
||||||
|
|
||||||
|
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
|
||||||
|
|
||||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||||
CMD ["/app/pocket-id"]
|
CMD ["/app/pocket-id"]
|
||||||
|
|||||||
12
backend/.air.toml
Normal file
12
backend/.air.toml
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
root = "."
|
||||||
|
tmp_dir = ".bin"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
bin = "./.bin/pocket-id"
|
||||||
|
cmd = "CGO_ENABLED=0 go build -o ./.bin/pocket-id ./cmd"
|
||||||
|
exclude_dir = ["resources", ".bin", "data"]
|
||||||
|
exclude_regex = [".*_test\\.go"]
|
||||||
|
stop_on_error = true
|
||||||
|
|
||||||
|
[misc]
|
||||||
|
clean_on_exit = true
|
||||||
@@ -1,15 +1,9 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
|
|
||||||
_ "time/tzdata"
|
_ "time/tzdata"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/cmds"
|
"github.com/pocket-id/pocket-id/backend/internal/cmds"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// @title Pocket ID API
|
// @title Pocket ID API
|
||||||
@@ -17,27 +11,5 @@ import (
|
|||||||
// @description.markdown
|
// @description.markdown
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
// Get the command
|
cmds.Execute()
|
||||||
// By default, this starts the server
|
|
||||||
var cmd string
|
|
||||||
flag.Parse()
|
|
||||||
args := flag.Args()
|
|
||||||
if len(args) > 0 {
|
|
||||||
cmd = args[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
switch cmd {
|
|
||||||
case "version":
|
|
||||||
fmt.Println("pocket-id " + common.Version)
|
|
||||||
case "one-time-access-token":
|
|
||||||
err = cmds.OneTimeAccessToken(args)
|
|
||||||
default:
|
|
||||||
// Start the server
|
|
||||||
err = bootstrap.Bootstrap()
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
log.Fatal(err.Error())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@
|
|||||||
package frontend
|
package frontend
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"embed"
|
"embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -12,11 +14,55 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:dist/*
|
//go:embed all:dist/*
|
||||||
var frontendFS embed.FS
|
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 {
|
func RegisterFrontend(router *gin.Engine) error {
|
||||||
distFS, err := fs.Sub(frontendFS, "dist")
|
distFS, err := fs.Sub(frontendFS, "dist")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -27,13 +73,39 @@ func RegisterFrontend(router *gin.Engine) error {
|
|||||||
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
||||||
|
|
||||||
router.NoRoute(func(c *gin.Context) {
|
router.NoRoute(func(c *gin.Context) {
|
||||||
// Try to serve the requested file
|
|
||||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||||
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
|
|
||||||
// File doesn't exist, serve index.html instead
|
if strings.HasPrefix(path, "api/") {
|
||||||
c.Request.URL.Path = "/"
|
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)
|
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
103
backend/go.mod
103
backend/go.mod
@@ -1,94 +1,108 @@
|
|||||||
module github.com/pocket-id/pocket-id/backend
|
module github.com/pocket-id/pocket-id/backend
|
||||||
|
|
||||||
go 1.24.0
|
go 1.25
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/caarlos0/env/v11 v11.3.1
|
github.com/caarlos0/env/v11 v11.3.1
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3
|
||||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||||
github.com/disintegration/imaging v1.6.2
|
github.com/disintegration/imaging v1.6.2
|
||||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||||
github.com/emersion/go-smtp v0.21.3
|
github.com/emersion/go-smtp v0.21.3
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0
|
github.com/fxamacker/cbor/v2 v2.9.0
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-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/glebarez/sqlite v1.11.0
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0
|
github.com/go-co-op/gocron/v2 v2.16.3
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10
|
github.com/go-ldap/ldap/v3 v3.4.10
|
||||||
github.com/go-playground/validator/v10 v10.25.0
|
github.com/go-playground/validator/v10 v10.27.0
|
||||||
github.com/go-webauthn/webauthn v0.11.2
|
github.com/go-webauthn/webauthn v0.11.2
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
github.com/golang-migrate/migrate/v4 v4.18.3
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/hashicorp/go-uuid v1.0.3
|
github.com/hashicorp/go-uuid v1.0.3
|
||||||
|
github.com/jinzhu/copier v0.4.0
|
||||||
github.com/joho/godotenv v1.5.1
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1
|
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/mileusna/useragent v1.3.5
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
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
|
github.com/stretchr/testify v1.10.0
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
|
||||||
go.opentelemetry.io/otel v1.35.0
|
go.opentelemetry.io/otel v1.37.0
|
||||||
go.opentelemetry.io/otel/metric v1.35.0
|
go.opentelemetry.io/otel/log v0.13.0
|
||||||
|
go.opentelemetry.io/otel/metric v1.37.0
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0
|
go.opentelemetry.io/otel/sdk v1.35.0
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.10.0
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
||||||
go.opentelemetry.io/otel/trace v1.35.0
|
go.opentelemetry.io/otel/trace v1.37.0
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.42.0
|
||||||
golang.org/x/image v0.24.0
|
golang.org/x/image v0.31.0
|
||||||
golang.org/x/time v0.9.0
|
golang.org/x/sync v0.17.0
|
||||||
gorm.io/driver/postgres v1.5.11
|
golang.org/x/text v0.29.0
|
||||||
gorm.io/gorm v1.25.12
|
golang.org/x/time v0.13.0
|
||||||
|
gorm.io/driver/postgres v1.6.0
|
||||||
|
gorm.io/gorm v1.31.0
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/bytedance/sonic v1.12.10 // indirect
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||||
github.com/disintegration/gift v1.1.2 // indirect
|
github.com/disintegration/gift v1.1.2 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||||
github.com/go-logr/logr v1.4.2 // indirect
|
github.com/go-logr/logr v1.4.3 // indirect
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-webauthn/x v0.1.16 // indirect
|
github.com/go-webauthn/x v0.1.23 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||||
github.com/google/go-tpm v0.9.3 // indirect
|
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||||
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
|
github.com/google/go-tpm v0.9.5 // indirect
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 // indirect
|
|
||||||
github.com/lestrrat-go/option v1.0.1 // indirect
|
github.com/lestrrat-go/option v1.0.1 // indirect
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||||
github.com/lib/pq v1.10.9 // indirect
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||||
github.com/prometheus/client_model v0.6.1 // indirect
|
github.com/prometheus/client_model v0.6.1 // indirect
|
||||||
@@ -97,8 +111,10 @@ require (
|
|||||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||||
github.com/segmentio/asm v1.2.0 // indirect
|
github.com/segmentio/asm v1.2.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/valyala/fastjson v1.6.4 // indirect
|
||||||
github.com/x448/float16 v0.8.4 // indirect
|
github.com/x448/float16 v0.8.4 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
|
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
|
||||||
@@ -113,23 +129,20 @@ require (
|
|||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
|
||||||
go.opentelemetry.io/otel/log v0.10.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/sdk/log v0.10.0 // indirect
|
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||||
go.uber.org/atomic v1.11.0 // indirect
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
golang.org/x/arch v0.14.0 // indirect
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||||
golang.org/x/net v0.38.0 // indirect
|
golang.org/x/net v0.43.0 // indirect
|
||||||
golang.org/x/sync v0.14.0 // indirect
|
golang.org/x/oauth2 v0.27.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
golang.org/x/text v0.23.0 // indirect
|
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||||
google.golang.org/grpc v1.71.0 // indirect
|
google.golang.org/grpc v1.71.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.5 // indirect
|
google.golang.org/protobuf v1.36.7 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
modernc.org/libc v1.65.6 // indirect
|
modernc.org/libc v1.66.7 // indirect
|
||||||
modernc.org/mathutil v1.7.1 // indirect
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
modernc.org/memory v1.10.0 // indirect
|
modernc.org/memory v1.11.0 // indirect
|
||||||
modernc.org/sqlite v1.37.0 // indirect
|
modernc.org/sqlite v1.38.2 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
235
backend/go.sum
235
backend/go.sum
@@ -8,27 +8,28 @@ github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7V
|
|||||||
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||||
github.com/bytedance/sonic v1.12.10 h1:uVCQr6oS5669E9ZVW0HyksTLfNS7Q/9hV6IVS4nEMsI=
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
|
||||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
|
||||||
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||||
@@ -51,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/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
|
||||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
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 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
|
||||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
@@ -80,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/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||||
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
|
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||||
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
||||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
||||||
|
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
|
||||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||||
|
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||||
|
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||||
|
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||||
|
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
@@ -118,12 +129,14 @@ github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9
|
|||||||
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||||
@@ -138,6 +151,8 @@ github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh6
|
|||||||
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
|
||||||
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
|
||||||
|
github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8=
|
||||||
|
github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@@ -150,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/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
@@ -162,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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
|
github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
|
||||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
|
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
|
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
|
||||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
|
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk=
|
||||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||||
|
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||||
|
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||||
|
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||||
@@ -201,10 +218,10 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
|
|||||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
|
github.com/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.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
|
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
@@ -223,12 +240,16 @@ github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
|||||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
|
||||||
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||||
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
|
||||||
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
@@ -236,18 +257,21 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
|||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||||
|
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
|
||||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
|
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
|
||||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
|
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
|
||||||
@@ -256,8 +280,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.
|
|||||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
|
||||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
|
||||||
@@ -280,47 +304,49 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX
|
|||||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
|
||||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
|
||||||
go.opentelemetry.io/otel/log v0.10.0 h1:1CXmspaRITvFcjA4kyVszuG4HjA61fPDxMb7q3BuyF0=
|
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
|
||||||
go.opentelemetry.io/otel/log v0.10.0/go.mod h1:PbVdm9bXKku/gL0oFfUF4wwsQsOPlpo4VEqjvxih+FM=
|
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
|
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
|
||||||
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo=
|
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 h1:y5zboxd6LQAqYIhHnB48p0ByQ/GnQx2BE33L8BOHQkI=
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||||
golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6/go.mod h1:U6Lno4MTRCDY+Ba7aCcauB9T60gsv5s4ralQzP72ZoQ=
|
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
|
||||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
@@ -332,8 +358,11 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
|||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||||
|
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -341,8 +370,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
|||||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
@@ -355,8 +384,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
|
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||||
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -367,6 +396,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
|||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
@@ -375,27 +405,29 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
golang.org/x/time v0.13.0 h1:eUlYslOIt32DgYD6utsuUeHs4d7AsEYLuIAdg7FlYgI=
|
||||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
golang.org/x/time v0.13.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||||
@@ -403,32 +435,33 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
|||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||||
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
|
||||||
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
modernc.org/libc v1.65.6 h1:OhJUhmuJ6MVZdqL5qmnd0/my46DKGFhSX4WOR7ijfyE=
|
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||||
modernc.org/libc v1.65.6/go.mod h1:MOiGAM9lrMBT9L8xT1nO41qYl5eg9gCp9/kWhz5L7WA=
|
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||||
|
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
|
||||||
|
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
|
||||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
modernc.org/memory v1.10.0 h1:fzumd51yQ1DxcOxSO+S6X7+QTuVU+n8/Aj7swYjFfC4=
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
modernc.org/memory v1.10.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI=
|
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||||
modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM=
|
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"bytes"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"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
|
// 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"
|
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||||
|
|
||||||
sourceFiles, err := resources.FS.ReadDir("images")
|
sourceFiles, err := resources.FS.ReadDir("images")
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
log.Fatalf("Error reading directory: %v", err)
|
return fmt.Errorf("failed to read directory: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
destinationFiles, err := os.ReadDir(dirPath)
|
destinationFiles, err := os.ReadDir(dirPath)
|
||||||
if err != nil && !os.IsNotExist(err) {
|
if err != nil && !os.IsNotExist(err) {
|
||||||
log.Fatalf("Error reading directory: %v", err)
|
return fmt.Errorf("failed to read directory: %w", err)
|
||||||
|
}
|
||||||
|
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
|
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||||
for _, sourceFile := range sourceFiles {
|
for _, sourceFile := range sourceFiles {
|
||||||
if sourceFile.IsDir() || imageAlreadyExists(sourceFile.Name(), destinationFiles) {
|
// Skip if it's a directory
|
||||||
|
if sourceFile.IsDir() {
|
||||||
continue
|
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)
|
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Error copying file: %v", err)
|
return fmt.Errorf("failed to copy file: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func 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 {
|
type imageHashMap map[string][]byte
|
||||||
for _, destinationFile := range destinationFiles {
|
|
||||||
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName)
|
|
||||||
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name())
|
|
||||||
|
|
||||||
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 true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return false
|
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 {
|
func getImageNameWithoutExtension(fileName string) string {
|
||||||
idx := strings.LastIndexByte(fileName, '.')
|
idx := strings.LastIndexByte(fileName, '.')
|
||||||
if idx < 1 {
|
if idx < 1 {
|
||||||
// No dot found, or fileName starts with a dot
|
// No dot found, or fileName starts with a dot
|
||||||
return fileName
|
return fileName
|
||||||
}
|
}
|
||||||
|
|
||||||
return fileName[:idx]
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||||
@@ -11,23 +11,26 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func Bootstrap() error {
|
func Bootstrap(ctx context.Context) error {
|
||||||
// Get a context that is canceled when the application is stopping
|
// Initialize the observability stack, including the logger, distributed tracing, and metrics
|
||||||
ctx := signals.SignalContext(context.Background())
|
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||||
|
|
||||||
initApplicationImages()
|
|
||||||
|
|
||||||
// Initialize the tracer and metrics exporter
|
|
||||||
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
|
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
|
||||||
}
|
}
|
||||||
|
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||||
|
|
||||||
|
err = initApplicationImages()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Connect to the database
|
// Connect to the database
|
||||||
db := NewDatabase()
|
db, err := NewDatabase()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to initialize database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Create all services
|
// Create all services
|
||||||
svc, err := initServices(ctx, db, httpClient)
|
svc, err := initServices(ctx, db, httpClient)
|
||||||
@@ -59,13 +62,14 @@ func Bootstrap() error {
|
|||||||
|
|
||||||
// Invoke all shutdown functions
|
// Invoke all shutdown functions
|
||||||
// We give these a timeout of 5s
|
// We give these a timeout of 5s
|
||||||
|
// Note: we use a background context because the run context has been canceled already
|
||||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
defer shutdownCancel()
|
defer shutdownCancel()
|
||||||
err = utils.
|
err = utils.
|
||||||
NewServiceRunner(shutdownFns...).
|
NewServiceRunner(shutdownFns...).
|
||||||
Run(shutdownCtx)
|
Run(shutdownCtx) //nolint:contextcheck
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Error shutting down services: %v", err)
|
slog.Error("Error shutting down services", slog.Any("error", err))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ package bootstrap
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -14,51 +15,57 @@ import (
|
|||||||
"github.com/golang-migrate/migrate/v4/database"
|
"github.com/golang-migrate/migrate/v4/database"
|
||||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||||
|
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
gormLogger "gorm.io/gorm/logger"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewDatabase() (db *gorm.DB) {
|
func NewDatabase() (db *gorm.DB, err error) {
|
||||||
db, err := connectDatabase()
|
db, err = connectDatabase()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to connect to database: %v", err)
|
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||||
}
|
}
|
||||||
sqlDb, err := db.DB()
|
sqlDb, err := db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to get sql.DB: %v", err)
|
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Choose the correct driver for the database provider
|
// Choose the correct driver for the database provider
|
||||||
var driver database.Driver
|
var driver database.Driver
|
||||||
switch common.EnvConfig.DbProvider {
|
switch common.EnvConfig.DbProvider {
|
||||||
case common.DbProviderSqlite:
|
case common.DbProviderSqlite:
|
||||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{})
|
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
|
||||||
|
NoTxWrap: true,
|
||||||
|
})
|
||||||
case common.DbProviderPostgres:
|
case common.DbProviderPostgres:
|
||||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||||
default:
|
default:
|
||||||
// Should never happen at this point
|
// Should never happen at this point
|
||||||
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to create migration driver: %v", err)
|
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run migrations
|
// Run migrations
|
||||||
if err := migrateDatabase(driver); err != nil {
|
if err := migrateDatabase(driver); err != nil {
|
||||||
log.Fatalf("failed to run migrations: %v", err)
|
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func migrateDatabase(driver database.Driver) error {
|
func migrateDatabase(driver database.Driver) error {
|
||||||
// Use the embedded migrations
|
// Embedded migrations via iofs
|
||||||
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
|
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||||
|
source, err := iofs.New(resources.FS, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||||
}
|
}
|
||||||
@@ -68,14 +75,66 @@ func migrateDatabase(driver database.Driver) error {
|
|||||||
return fmt.Errorf("failed to create migration instance: %w", err)
|
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = m.Up()
|
requiredVersion, err := getRequiredMigrationVersion(path)
|
||||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
return fmt.Errorf("failed to get last migration version: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentVersion, _, _ := m.Version()
|
||||||
|
if currentVersion > requiredVersion {
|
||||||
|
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
|
||||||
|
if !common.EnvConfig.AllowDowngrade {
|
||||||
|
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
|
||||||
|
}
|
||||||
|
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||||
|
return migrateDatabaseFromGitHub(driver, requiredVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("failed to apply embedded migrations: %w", err)
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
|
||||||
|
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
|
||||||
|
|
||||||
|
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||||
|
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
|
||||||
|
func getRequiredMigrationVersion(path string) (uint, error) {
|
||||||
|
entries, err := resources.FS.ReadDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("failed to read migration directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var maxVersion uint
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := entry.Name()
|
||||||
|
var version uint
|
||||||
|
n, err := fmt.Sscanf(name, "%d_", &version)
|
||||||
|
if err == nil && n == 1 {
|
||||||
|
if version > maxVersion {
|
||||||
|
maxVersion = version
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxVersion, nil
|
||||||
|
}
|
||||||
|
|
||||||
func connectDatabase() (db *gorm.DB, err error) {
|
func connectDatabase() (db *gorm.DB, err error) {
|
||||||
var dialector gorm.Dialector
|
var dialector gorm.Dialector
|
||||||
|
|
||||||
@@ -85,13 +144,20 @@ func connectDatabase() (db *gorm.DB, err error) {
|
|||||||
if common.EnvConfig.DbConnectionString == "" {
|
if common.EnvConfig.DbConnectionString == "" {
|
||||||
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
|
|
||||||
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
|
sqliteutil.RegisterSqliteFunctions()
|
||||||
}
|
|
||||||
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||||
|
err = ensureSqliteTempDir(filepath.Dir(dbPath))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
dialector = sqlite.Open(connString)
|
dialector = sqlite.Open(connString)
|
||||||
case common.DbProviderPostgres:
|
case common.DbProviderPostgres:
|
||||||
if common.EnvConfig.DbConnectionString == "" {
|
if common.EnvConfig.DbConnectionString == "" {
|
||||||
@@ -105,38 +171,68 @@ func connectDatabase() (db *gorm.DB, err error) {
|
|||||||
for i := 1; i <= 3; i++ {
|
for i := 1; i <= 3; i++ {
|
||||||
db, err = gorm.Open(dialector, &gorm.Config{
|
db, err = gorm.Open(dialector, &gorm.Config{
|
||||||
TranslateError: true,
|
TranslateError: true,
|
||||||
Logger: getLogger(),
|
Logger: getGormLogger(),
|
||||||
})
|
})
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
|
||||||
return db, nil
|
return db, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||||
time.Sleep(3 * time.Second)
|
time.Sleep(3 * time.Second)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||||
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
|
||||||
|
if !strings.HasPrefix(connString, "file:") {
|
||||||
|
connString = "file:" + connString
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're using an in-memory database
|
||||||
|
isMemoryDB := isSqliteInMemory(connString)
|
||||||
|
|
||||||
|
// Parse the connection string
|
||||||
|
connStringUrl, err := url.Parse(connString)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert options for the old SQLite driver to the new one
|
||||||
|
convertSqlitePragmaArgs(connStringUrl)
|
||||||
|
|
||||||
|
// Add the default and required params
|
||||||
|
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("invalid SQLite connection string: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the absolute path to the database
|
||||||
|
// Here, we know for a fact that the ? is present
|
||||||
|
parsedConnString = connStringUrl.String()
|
||||||
|
idx := strings.IndexRune(parsedConnString, '?')
|
||||||
|
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedConnString, dbPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
// The official C implementation of SQLite allows some additional properties in the connection string
|
// The official C implementation of SQLite allows some additional properties in the connection string
|
||||||
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
|
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
|
||||||
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
|
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
|
||||||
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
|
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
|
||||||
func parseSqliteConnectionString(connString string) (string, error) {
|
// Note this function updates connStringUrl.
|
||||||
if !strings.HasPrefix(connString, "file:") {
|
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
|
||||||
connString = "file:" + connString
|
|
||||||
}
|
|
||||||
|
|
||||||
connStringUrl, err := url.Parse(connString)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
|
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
|
||||||
// This only includes a subset of options, excluding those that are not relevant to us
|
// This only includes a subset of options, excluding those that are not relevant to us
|
||||||
qs := make(url.Values, len(connStringUrl.Query()))
|
qs := make(url.Values, len(connStringUrl.Query()))
|
||||||
for k, v := range connStringUrl.Query() {
|
for k, v := range connStringUrl.Query() {
|
||||||
switch k {
|
switch strings.ToLower(k) {
|
||||||
case "_auto_vacuum", "_vacuum":
|
case "_auto_vacuum", "_vacuum":
|
||||||
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
|
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
|
||||||
case "_busy_timeout", "_timeout":
|
case "_busy_timeout", "_timeout":
|
||||||
@@ -157,29 +253,179 @@ func parseSqliteConnectionString(connString string) (string, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the connStringUrl object
|
||||||
connStringUrl.RawQuery = qs.Encode()
|
connStringUrl.RawQuery = qs.Encode()
|
||||||
|
|
||||||
return connStringUrl.String(), nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLogger() logger.Interface {
|
// Adds the default (and some required) parameters to the SQLite connection string.
|
||||||
isProduction := common.EnvConfig.AppEnv == "production"
|
// Note this function updates connStringUrl.
|
||||||
|
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
|
||||||
|
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
|
||||||
|
// Copyright (C) 2023 The Dapr Authors
|
||||||
|
// License: Apache2
|
||||||
|
const defaultBusyTimeout = 2500 * time.Millisecond
|
||||||
|
|
||||||
var logLevel logger.LogLevel
|
// Get the "query string" from the connection string if present
|
||||||
if isProduction {
|
qs := connStringUrl.Query()
|
||||||
logLevel = logger.Error
|
if len(qs) == 0 {
|
||||||
} else {
|
qs = make(url.Values, 2)
|
||||||
logLevel = logger.Info
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return logger.New(
|
// If the database is in-memory, we must ensure that cache=shared is set
|
||||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
if isMemoryDB {
|
||||||
logger.Config{
|
qs["cache"] = []string{"shared"}
|
||||||
SlowThreshold: 200 * time.Millisecond,
|
}
|
||||||
LogLevel: logLevel,
|
|
||||||
IgnoreRecordNotFoundError: isProduction,
|
// Check if the database is read-only or immutable
|
||||||
ParameterizedQueries: isProduction,
|
isReadOnly := false
|
||||||
Colorful: !isProduction,
|
if len(qs["mode"]) > 0 {
|
||||||
},
|
// Keep the first value only
|
||||||
)
|
qs["mode"] = []string{
|
||||||
|
strings.ToLower(qs["mode"][0]),
|
||||||
|
}
|
||||||
|
if qs["mode"][0] == "ro" {
|
||||||
|
isReadOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(qs["immutable"]) > 0 {
|
||||||
|
// Keep the first value only
|
||||||
|
qs["immutable"] = []string{
|
||||||
|
strings.ToLower(qs["immutable"][0]),
|
||||||
|
}
|
||||||
|
if qs["immutable"][0] == "1" {
|
||||||
|
isReadOnly = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
|
||||||
|
if len(qs["_txlock"]) > 0 {
|
||||||
|
// Keep the first value only
|
||||||
|
qs["_txlock"] = []string{
|
||||||
|
strings.ToLower(qs["_txlock"][0]),
|
||||||
|
}
|
||||||
|
if qs["_txlock"][0] != "immediate" {
|
||||||
|
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
qs["_txlock"] = []string{"immediate"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add pragma values
|
||||||
|
var hasBusyTimeout, hasJournalMode bool
|
||||||
|
if len(qs["_pragma"]) == 0 {
|
||||||
|
qs["_pragma"] = make([]string, 0, 3)
|
||||||
|
} else {
|
||||||
|
for _, p := range qs["_pragma"] {
|
||||||
|
p = strings.ToLower(p)
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(p, "busy_timeout"):
|
||||||
|
hasBusyTimeout = true
|
||||||
|
case strings.HasPrefix(p, "journal_mode"):
|
||||||
|
hasJournalMode = true
|
||||||
|
case strings.HasPrefix(p, "foreign_keys"):
|
||||||
|
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !hasBusyTimeout {
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
|
||||||
|
}
|
||||||
|
if !hasJournalMode {
|
||||||
|
switch {
|
||||||
|
case isMemoryDB:
|
||||||
|
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
|
||||||
|
case isReadOnly:
|
||||||
|
// Set the journaling mode to "DELETE" (the default) if the database is read-only
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
|
||||||
|
default:
|
||||||
|
// Enable WAL
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forcefully enable foreign keys
|
||||||
|
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
|
||||||
|
|
||||||
|
// Update the connStringUrl object
|
||||||
|
connStringUrl.RawQuery = qs.Encode()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSqliteInMemory returns true if the connection string is for an in-memory database.
|
||||||
|
func isSqliteInMemory(connString string) bool {
|
||||||
|
lc := strings.ToLower(connString)
|
||||||
|
|
||||||
|
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
|
||||||
|
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Another way is to pass "mode=memory" in the "query string"
|
||||||
|
idx := strings.IndexRune(lc, '?')
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
qs, _ := url.ParseQuery(lc[(idx + 1):])
|
||||||
|
|
||||||
|
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
|
||||||
|
// The default directory may not be writable when using a container with a read-only root file system
|
||||||
|
// See: https://www.sqlite.org/tempfiles.html
|
||||||
|
func ensureSqliteTempDir(dbPath string) error {
|
||||||
|
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
|
||||||
|
//
|
||||||
|
// - The SQLITE_TMPDIR environment variable
|
||||||
|
// - The TMPDIR environment variable
|
||||||
|
// - /var/tmp
|
||||||
|
// - /usr/tmp
|
||||||
|
// - /tmp
|
||||||
|
//
|
||||||
|
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
|
||||||
|
//
|
||||||
|
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
|
||||||
|
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
|
||||||
|
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
|
||||||
|
ok, err := utils.IsWritableDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
// We found a folder that's writable
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
|
||||||
|
err := os.Setenv("SQLITE_TMPDIR", dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getGormLogger() gormLogger.Interface {
|
||||||
|
loggerCfg := gormLogger.Config{
|
||||||
|
SlowThreshold: 200 * time.Millisecond,
|
||||||
|
IgnoreRecordNotFoundError: true,
|
||||||
|
LogLevel: gormLogger.Warn,
|
||||||
|
ParameterizedQueries: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
if common.EnvConfig.AppEnv == "debug" {
|
||||||
|
loggerCfg.IgnoreRecordNotFoundError = false
|
||||||
|
loggerCfg.LogLevel = gormLogger.Info
|
||||||
|
}
|
||||||
|
|
||||||
|
return gormLogger.NewSlogLogger(slog.Default(), loggerCfg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,23 +8,93 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestParseSqliteConnectionString(t *testing.T) {
|
func TestIsSqliteInMemory(t *testing.T) {
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
input string
|
connStr string
|
||||||
expected string
|
expected bool
|
||||||
expectedError bool
|
}{
|
||||||
|
{
|
||||||
|
name: "memory database with :memory:",
|
||||||
|
connStr: ":memory:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with file::memory:",
|
||||||
|
connStr: "file::memory:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with :MEMORY: (uppercase)",
|
||||||
|
connStr: ":MEMORY:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with FILE::MEMORY: (uppercase)",
|
||||||
|
connStr: "FILE::MEMORY:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "memory database with mixed case",
|
||||||
|
connStr: ":Memory:",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has mode=memory",
|
||||||
|
connStr: "file:data?mode=memory",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file database",
|
||||||
|
connStr: "data.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file database with path",
|
||||||
|
connStr: "/path/to/data.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "file database with file: prefix",
|
||||||
|
connStr: "file:data.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty string",
|
||||||
|
connStr: "",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "string containing memory but not at start",
|
||||||
|
connStr: "data:memory:.db",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "has mode=ro",
|
||||||
|
connStr: "file:data?mode=ro",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := isSqliteInMemory(tt.connStr)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertSqlitePragmaArgs(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "basic file path",
|
name: "basic file path",
|
||||||
input: "file:test.db",
|
input: "file:test.db",
|
||||||
expected: "file:test.db",
|
expected: "file:test.db",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "adds file: prefix if missing",
|
|
||||||
input: "test.db",
|
|
||||||
expected: "file:test.db",
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "converts _busy_timeout to pragma",
|
name: "converts _busy_timeout to pragma",
|
||||||
input: "file:test.db?_busy_timeout=5000",
|
input: "file:test.db?_busy_timeout=5000",
|
||||||
@@ -100,46 +170,161 @@ func TestParseSqliteConnectionString(t *testing.T) {
|
|||||||
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
|
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
|
||||||
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
|
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
resultURL, _ := url.Parse(tt.input)
|
||||||
|
convertSqlitePragmaArgs(resultURL)
|
||||||
|
|
||||||
|
// Parse both URLs to compare components independently
|
||||||
|
expectedURL, err := url.Parse(tt.expected)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Compare scheme and path components
|
||||||
|
compareQueryStrings(t, expectedURL, resultURL)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAddSqliteDefaultParameters(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
isMemoryDB bool
|
||||||
|
expected string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
{
|
{
|
||||||
name: "invalid URL format",
|
name: "basic file database",
|
||||||
input: "file:invalid#$%^&*@test.db",
|
input: "file:test.db",
|
||||||
expectedError: true,
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "in-memory database",
|
||||||
|
input: "file::memory:",
|
||||||
|
isMemoryDB: true,
|
||||||
|
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read-only database with mode=ro",
|
||||||
|
input: "file:test.db?mode=ro",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "immutable database",
|
||||||
|
input: "file:test.db?immutable=1",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with existing _txlock",
|
||||||
|
input: "file:test.db?_txlock=deferred",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with existing busy_timeout pragma",
|
||||||
|
input: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with existing journal_mode pragma",
|
||||||
|
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with forbidden foreign_keys pragma",
|
||||||
|
input: "file:test.db?_pragma=foreign_keys%280%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with multiple existing pragmas",
|
||||||
|
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "in-memory database with cache already set",
|
||||||
|
input: "file::memory:?cache=private",
|
||||||
|
isMemoryDB: true,
|
||||||
|
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with mode=rw (not read-only)",
|
||||||
|
input: "file:test.db?mode=rw",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with immutable=0 (not immutable)",
|
||||||
|
input: "file:test.db?immutable=0",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with mixed case mode=RO",
|
||||||
|
input: "file:test.db?mode=RO",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "database with mixed case immutable=1",
|
||||||
|
input: "file:test.db?immutable=1",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex database configuration",
|
||||||
|
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
|
||||||
|
isMemoryDB: false,
|
||||||
|
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
result, err := parseSqliteConnectionString(tt.input)
|
resultURL, err := url.Parse(tt.input)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
if tt.expectedError {
|
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Parse both URLs to compare components independently
|
|
||||||
expectedURL, err := url.Parse(tt.expected)
|
expectedURL, err := url.Parse(tt.expected)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
resultURL, err := url.Parse(result)
|
compareQueryStrings(t, expectedURL, resultURL)
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Compare scheme and path components
|
|
||||||
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
|
|
||||||
assert.Equal(t, expectedURL.Path, resultURL.Path)
|
|
||||||
|
|
||||||
// Compare query parameters regardless of order
|
|
||||||
expectedQuery := expectedURL.Query()
|
|
||||||
resultQuery := resultURL.Query()
|
|
||||||
|
|
||||||
assert.Len(t, expectedQuery, len(resultQuery))
|
|
||||||
|
|
||||||
for key, expectedValues := range expectedQuery {
|
|
||||||
resultValues, ok := resultQuery[key]
|
|
||||||
_ = assert.True(t, ok) &&
|
|
||||||
assert.ElementsMatch(t, expectedValues, resultValues)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
// Compare scheme and path components
|
||||||
|
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
|
||||||
|
assert.Equal(t, expectedURL.Path, resultURL.Path)
|
||||||
|
|
||||||
|
// Compare query parameters regardless of order
|
||||||
|
expectedQuery := expectedURL.Query()
|
||||||
|
resultQuery := resultURL.Query()
|
||||||
|
|
||||||
|
assert.Len(t, expectedQuery, len(resultQuery))
|
||||||
|
|
||||||
|
for key, expectedValues := range expectedQuery {
|
||||||
|
resultValues, ok := resultQuery[key]
|
||||||
|
_ = assert.True(t, ok) &&
|
||||||
|
assert.ElementsMatch(t, expectedValues, resultValues)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
@@ -14,7 +17,13 @@ import (
|
|||||||
func init() {
|
func init() {
|
||||||
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
|
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
|
||||||
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||||
testService := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
|
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||||
|
os.Exit(1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
controller.NewTestController(apiGroup, testService)
|
controller.NewTestController(apiGroup, testService)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
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,18 +4,21 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
sloggin "github.com/gin-contrib/slog"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
@@ -29,7 +32,8 @@ var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *
|
|||||||
func initRouter(db *gorm.DB, svc *services) utils.Service {
|
func initRouter(db *gorm.DB, svc *services) utils.Service {
|
||||||
runner, err := initRouterInternal(db, svc)
|
runner, err := initRouterInternal(db, svc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("failed to init router: %v", err)
|
slog.Error("Failed to init router", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
return runner
|
return runner
|
||||||
}
|
}
|
||||||
@@ -45,26 +49,27 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
gin.SetMode(gin.TestMode)
|
gin.SetMode(gin.TestMode)
|
||||||
}
|
}
|
||||||
|
|
||||||
r := gin.Default()
|
r := gin.New()
|
||||||
r.Use(gin.Logger())
|
initLogger(r)
|
||||||
|
|
||||||
if !common.EnvConfig.TrustProxy {
|
if !common.EnvConfig.TrustProxy {
|
||||||
_ = r.SetTrustedProxies(nil)
|
_ = r.SetTrustedProxies(nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
if common.EnvConfig.TracingEnabled {
|
if common.EnvConfig.TracingEnabled {
|
||||||
r.Use(otelgin.Middleware("pocket-id-backend"))
|
r.Use(otelgin.Middleware(common.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
|
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
|
||||||
|
|
||||||
// Setup global middleware
|
// Setup global middleware
|
||||||
r.Use(middleware.NewCorsMiddleware().Add())
|
r.Use(middleware.NewCorsMiddleware().Add())
|
||||||
|
r.Use(middleware.NewCspMiddleware().Add())
|
||||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||||
|
|
||||||
err := frontend.RegisterFrontend(r)
|
err := frontend.RegisterFrontend(r)
|
||||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||||
log.Println("Frontend is not included in the build. Skipping frontend registration.")
|
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,6 +88,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||||
|
controller.NewVersionController(apiGroup, svc.versionService)
|
||||||
|
|
||||||
// Add test controller in non-production environments
|
// Add test controller in non-production environments
|
||||||
if common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AppEnv != "production" {
|
||||||
@@ -101,21 +107,39 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
|
|
||||||
// Set up the server
|
// Set up the server
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port),
|
|
||||||
MaxHeaderBytes: 1 << 20,
|
MaxHeaderBytes: 1 << 20,
|
||||||
ReadHeaderTimeout: 10 * time.Second,
|
ReadHeaderTimeout: 10 * time.Second,
|
||||||
Handler: r,
|
Handler: r,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the listener
|
// Set up the listener
|
||||||
listener, err := net.Listen("tcp", srv.Addr)
|
network := "tcp"
|
||||||
|
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
|
||||||
|
if common.EnvConfig.UnixSocket != "" {
|
||||||
|
network = "unix"
|
||||||
|
addr = common.EnvConfig.UnixSocket
|
||||||
|
}
|
||||||
|
|
||||||
|
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create TCP listener: %w", err)
|
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the socket mode if using a Unix socket
|
||||||
|
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
|
||||||
|
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service runner function
|
// Service runner function
|
||||||
runFn := func(ctx context.Context) error {
|
runFn := func(ctx context.Context) error {
|
||||||
log.Printf("Server listening on %s", srv.Addr)
|
slog.Info("Server listening", slog.String("addr", addr))
|
||||||
|
|
||||||
// Start the server in a background goroutine
|
// Start the server in a background goroutine
|
||||||
go func() {
|
go func() {
|
||||||
@@ -124,7 +148,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
// Next call blocks until the server is shut down
|
// Next call blocks until the server is shut down
|
||||||
srvErr := srv.Serve(listener)
|
srvErr := srv.Serve(listener)
|
||||||
if srvErr != http.ErrServerClosed {
|
if srvErr != http.ErrServerClosed {
|
||||||
log.Fatalf("Error starting app server: %v", srvErr)
|
slog.Error("Error starting app server", "error", srvErr)
|
||||||
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -132,7 +157,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
err = systemd.SdNotifyReady()
|
err = systemd.SdNotifyReady()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Log the error only
|
// Log the error only
|
||||||
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
|
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Block until the context is canceled
|
// Block until the context is canceled
|
||||||
@@ -145,7 +170,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
shutdownCancel()
|
shutdownCancel()
|
||||||
if shutdownErr != nil {
|
if shutdownErr != nil {
|
||||||
// Log the error only (could be context canceled)
|
// Log the error only (could be context canceled)
|
||||||
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
|
slog.Warn("App server shutdown error", "error", shutdownErr)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -153,3 +178,29 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
|||||||
|
|
||||||
return runFn, nil
|
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,30 +23,47 @@ type services struct {
|
|||||||
userGroupService *service.UserGroupService
|
userGroupService *service.UserGroupService
|
||||||
ldapService *service.LdapService
|
ldapService *service.LdapService
|
||||||
apiKeyService *service.ApiKeyService
|
apiKeyService *service.ApiKeyService
|
||||||
|
versionService *service.VersionService
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initializes all services
|
// Initializes all services
|
||||||
// The context should be used by services only for initialization, and not for running
|
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
||||||
func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
|
||||||
svc = &services{}
|
svc = &services{}
|
||||||
|
|
||||||
svc.appConfigService = service.NewAppConfigService(initCtx, db)
|
svc.appConfigService, err = service.NewAppConfigService(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create app config service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("unable to create email service: %w", err)
|
return nil, fmt.Errorf("failed to create email service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
svc.geoLiteService = service.NewGeoLiteService(httpClient)
|
||||||
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
svc.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
||||||
svc.jwtService = service.NewJwtService(svc.appConfigService)
|
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
|
||||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
svc.customClaimService = service.NewCustomClaimService(db)
|
svc.customClaimService = service.NewCustomClaimService(db)
|
||||||
svc.oidcService = service.NewOidcService(db, svc.jwtService, svc.appConfigService, svc.auditLogService, svc.customClaimService)
|
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.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
|
||||||
|
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
|
||||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||||
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
|
||||||
|
svc.versionService = service.NewVersionService(httpClient)
|
||||||
|
|
||||||
return svc, nil
|
return svc, nil
|
||||||
}
|
}
|
||||||
|
|||||||
83
backend/internal/cmds/healthcheck.go
Normal file
83
backend/internal/cmds/healthcheck.go
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type healthcheckFlags struct {
|
||||||
|
Endpoint string
|
||||||
|
Verbose bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var flags healthcheckFlags
|
||||||
|
|
||||||
|
healthcheckCmd := &cobra.Command{
|
||||||
|
Use: "healthcheck",
|
||||||
|
Short: "Performs a healthcheck of a running Pocket ID instance",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(cmd.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
url := flags.Endpoint + "/healthz"
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"Failed to create request object",
|
||||||
|
"error", err,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"Failed to perform request",
|
||||||
|
"error", err,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer res.Body.Close()
|
||||||
|
|
||||||
|
if res.StatusCode < 200 || res.StatusCode >= 300 {
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx,
|
||||||
|
"Healthcheck failed",
|
||||||
|
"status", res.StatusCode,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if flags.Verbose {
|
||||||
|
slog.InfoContext(ctx,
|
||||||
|
"Healthcheck succeeded",
|
||||||
|
"status", res.StatusCode,
|
||||||
|
"url", url,
|
||||||
|
"ms", time.Since(start).Milliseconds(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
|
||||||
|
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(healthcheckCmd)
|
||||||
|
}
|
||||||
113
backend/internal/cmds/key_rotate.go
Normal file
113
backend/internal/cmds/key_rotate.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||||
|
)
|
||||||
|
|
||||||
|
type keyRotateFlags struct {
|
||||||
|
Alg string
|
||||||
|
Crv string
|
||||||
|
Yes bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
var flags keyRotateFlags
|
||||||
|
|
||||||
|
keyRotateCmd := &cobra.Command{
|
||||||
|
Use: "key-rotate",
|
||||||
|
Short: "Generates a new token signing key and replaces the current one",
|
||||||
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
db, err := bootstrap.NewDatabase()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return keyRotate(cmd.Context(), flags, db, &common.EnvConfig)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
keyRotateCmd.Flags().StringVarP(&flags.Alg, "alg", "a", "RS256", "Key algorithm. Supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
|
||||||
|
keyRotateCmd.Flags().StringVarP(&flags.Crv, "crv", "c", "", "Curve name when using EdDSA keys. Supported values: Ed25519")
|
||||||
|
keyRotateCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Do not prompt for confirmation")
|
||||||
|
|
||||||
|
rootCmd.AddCommand(keyRotateCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig *common.EnvConfigSchema) error {
|
||||||
|
// Validate the flags
|
||||||
|
switch strings.ToUpper(flags.Alg) {
|
||||||
|
case jwa.RS256().String(), jwa.RS384().String(), jwa.RS512().String(),
|
||||||
|
jwa.ES256().String(), jwa.ES384().String(), jwa.ES512().String():
|
||||||
|
// All good, but uppercase it for consistency
|
||||||
|
flags.Alg = strings.ToUpper(flags.Alg)
|
||||||
|
case strings.ToUpper(jwa.EdDSA().String()):
|
||||||
|
// Ensure Crv is set and valid
|
||||||
|
switch strings.ToUpper(flags.Crv) {
|
||||||
|
case strings.ToUpper(jwa.Ed25519().String()):
|
||||||
|
// All good, but ensure consistency in casing
|
||||||
|
flags.Crv = jwa.Ed25519().String()
|
||||||
|
case "":
|
||||||
|
return errors.New("a curve name is required when algorithm is EdDSA")
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported EdDSA curve; supported values: Ed25519")
|
||||||
|
}
|
||||||
|
case "":
|
||||||
|
return errors.New("key algorithm is required")
|
||||||
|
default:
|
||||||
|
return errors.New("unsupported key algorithm; supported values: RS256, RS384, RS512, ES256, ES384, ES512, EdDSA")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !flags.Yes {
|
||||||
|
fmt.Println("WARNING: Rotating the private key will invalidate all existing tokens. Both pocket-id and all client applications will likely need to be restarted.")
|
||||||
|
ok, err := utils.PromptForConfirmation("Confirm")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
fmt.Println("Aborted")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init the services we need
|
||||||
|
appConfigService, err := service.NewAppConfigService(ctx, db)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create app config service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the key provider
|
||||||
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, appConfigService.GetDbConfig().InstanceID.Value)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get key provider: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate a new key
|
||||||
|
key, err := jwkutils.GenerateKey(flags.Alg, flags.Crv)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the key
|
||||||
|
err = keyProvider.SaveKey(key)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to store new key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("Key rotated successfully")
|
||||||
|
fmt.Println("Note: if pocket-id is running, you will need to restart it for the new key to be loaded")
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
216
backend/internal/cmds/key_rotate_test.go
Normal file
216
backend/internal/cmds/key_rotate_test.go
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
|
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||||
|
testingutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestKeyRotate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
flags keyRotateFlags
|
||||||
|
wantErr bool
|
||||||
|
errMsg string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid RS256",
|
||||||
|
flags: keyRotateFlags{
|
||||||
|
Alg: "RS256",
|
||||||
|
Yes: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid EdDSA with Ed25519",
|
||||||
|
flags: keyRotateFlags{
|
||||||
|
Alg: "EdDSA",
|
||||||
|
Crv: "Ed25519",
|
||||||
|
Yes: true,
|
||||||
|
},
|
||||||
|
wantErr: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid algorithm",
|
||||||
|
flags: keyRotateFlags{
|
||||||
|
Alg: "INVALID",
|
||||||
|
Yes: true,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "unsupported key algorithm",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "EdDSA without curve",
|
||||||
|
flags: keyRotateFlags{
|
||||||
|
Alg: "EdDSA",
|
||||||
|
Yes: true,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "a curve name is required when algorithm is EdDSA",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty algorithm",
|
||||||
|
flags: keyRotateFlags{
|
||||||
|
Alg: "",
|
||||||
|
Yes: true,
|
||||||
|
},
|
||||||
|
wantErr: true,
|
||||||
|
errMsg: "key algorithm is required",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Run("file storage", func(t *testing.T) {
|
||||||
|
testKeyRotateWithFileStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("database storage", func(t *testing.T) {
|
||||||
|
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||||
|
// Create temporary directory for keys
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
keysPath := filepath.Join(tempDir, "keys")
|
||||||
|
err := os.MkdirAll(keysPath, 0755)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set up file storage config
|
||||||
|
envConfig := &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: keysPath,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test database
|
||||||
|
db := testingutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
|
// Initialize app config service and create instance
|
||||||
|
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||||
|
|
||||||
|
// Check if key exists before rotation
|
||||||
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run the key rotation
|
||||||
|
err = keyRotate(t.Context(), flags, db, envConfig)
|
||||||
|
|
||||||
|
if wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
if errMsg != "" {
|
||||||
|
require.ErrorContains(t, err, errMsg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify key was created
|
||||||
|
key, err := keyProvider.LoadKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, key)
|
||||||
|
|
||||||
|
// Verify the algorithm matches what we requested
|
||||||
|
alg, _ := key.Algorithm()
|
||||||
|
assert.NotEmpty(t, alg)
|
||||||
|
if flags.Alg != "" {
|
||||||
|
expectedAlg := flags.Alg
|
||||||
|
if expectedAlg == "EdDSA" {
|
||||||
|
// EdDSA keys should have the EdDSA algorithm
|
||||||
|
assert.Equal(t, "EdDSA", alg.String())
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, expectedAlg, alg.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||||
|
// Set up database storage config
|
||||||
|
envConfig := &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "database",
|
||||||
|
EncryptionKey: []byte("test-encryption-key-characters-long"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create test database
|
||||||
|
db := testingutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
|
// Initialize app config service and create instance
|
||||||
|
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||||
|
require.NoError(t, err)
|
||||||
|
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||||
|
|
||||||
|
// Get key provider
|
||||||
|
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Run the key rotation
|
||||||
|
err = keyRotate(t.Context(), flags, db, envConfig)
|
||||||
|
|
||||||
|
if wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
if errMsg != "" {
|
||||||
|
require.ErrorContains(t, err, errMsg)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify key was created
|
||||||
|
key, err := keyProvider.LoadKey()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, key)
|
||||||
|
|
||||||
|
// Verify the algorithm matches what we requested
|
||||||
|
alg, _ := key.Algorithm()
|
||||||
|
assert.NotEmpty(t, alg)
|
||||||
|
if flags.Alg != "" {
|
||||||
|
expectedAlg := flags.Alg
|
||||||
|
if expectedAlg == "EdDSA" {
|
||||||
|
// EdDSA keys should have the EdDSA algorithm
|
||||||
|
assert.Equal(t, "EdDSA", alg.String())
|
||||||
|
} else {
|
||||||
|
assert.Equal(t, expectedAlg, alg.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestKeyRotateMultipleAlgorithms(t *testing.T) {
|
||||||
|
algorithms := []struct {
|
||||||
|
alg string
|
||||||
|
crv string
|
||||||
|
}{
|
||||||
|
{"RS256", ""},
|
||||||
|
{"RS384", ""},
|
||||||
|
// Skip RSA-4096 key generation test as it can take a long time
|
||||||
|
// {"RS512", ""},
|
||||||
|
{"ES256", ""},
|
||||||
|
{"ES384", ""},
|
||||||
|
{"ES512", ""},
|
||||||
|
{"EdDSA", "Ed25519"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, algo := range algorithms {
|
||||||
|
t.Run(algo.alg, func(t *testing.T) {
|
||||||
|
// Test with database storage for all algorithms
|
||||||
|
testKeyRotateWithDatabaseStorage(t, keyRotateFlags{
|
||||||
|
Alg: algo.alg,
|
||||||
|
Crv: algo.crv,
|
||||||
|
Yes: true,
|
||||||
|
}, false, "")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,77 +6,80 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// OneTimeAccessToken creates a one-time access token for the given user
|
var oneTimeAccessTokenCmd = &cobra.Command{
|
||||||
// Args must contain the username or email of the user
|
Use: "one-time-access-token [username or email]",
|
||||||
func OneTimeAccessToken(args []string) error {
|
Short: "Generates a one-time access token for the given user",
|
||||||
// Get a context that is canceled when the application is stopping
|
Args: cobra.ExactArgs(1),
|
||||||
ctx := signals.SignalContext(context.Background())
|
RunE: func(cmd *cobra.Command, args []string) error {
|
||||||
|
// Get the username or email of the user
|
||||||
|
userArg := args[0]
|
||||||
|
|
||||||
// Get the username or email of the user
|
// Connect to the database
|
||||||
// Note length is 2 because the first argument is always the command (one-time-access-token)
|
db, err := bootstrap.NewDatabase()
|
||||||
if len(args) != 2 {
|
if err != nil {
|
||||||
return errors.New("missing username or email of user; usage: one-time-access-token <username or email>")
|
return err
|
||||||
}
|
|
||||||
userArg := args[1]
|
|
||||||
|
|
||||||
// Connect to the database
|
|
||||||
db := bootstrap.NewDatabase()
|
|
||||||
|
|
||||||
// Create the access token
|
|
||||||
var oneTimeAccessToken *model.OneTimeAccessToken
|
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
// Load the user to retrieve the user ID
|
|
||||||
var user model.User
|
|
||||||
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer queryCancel()
|
|
||||||
txErr := tx.
|
|
||||||
WithContext(queryCtx).
|
|
||||||
Where("username = ? OR email = ?", userArg, userArg).
|
|
||||||
First(&user).
|
|
||||||
Error
|
|
||||||
switch {
|
|
||||||
case errors.Is(txErr, gorm.ErrRecordNotFound):
|
|
||||||
return errors.New("user not found")
|
|
||||||
case txErr != nil:
|
|
||||||
return fmt.Errorf("failed to query for user: %w", txErr)
|
|
||||||
case user.ID == "":
|
|
||||||
return errors.New("invalid user loaded: ID is empty")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a new access token that expires in 1 hour
|
// Create the access token
|
||||||
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Now().Add(time.Hour))
|
var oneTimeAccessToken *model.OneTimeAccessToken
|
||||||
if txErr != nil {
|
err = db.Transaction(func(tx *gorm.DB) error {
|
||||||
return fmt.Errorf("failed to generate access token: %w", txErr)
|
// Load the user to retrieve the user ID
|
||||||
|
var user model.User
|
||||||
|
queryCtx, queryCancel := context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||||
|
defer queryCancel()
|
||||||
|
txErr := tx.
|
||||||
|
WithContext(queryCtx).
|
||||||
|
Where("username = ? OR email = ?", userArg, userArg).
|
||||||
|
First(&user).
|
||||||
|
Error
|
||||||
|
switch {
|
||||||
|
case errors.Is(txErr, gorm.ErrRecordNotFound):
|
||||||
|
return errors.New("user not found")
|
||||||
|
case txErr != nil:
|
||||||
|
return fmt.Errorf("failed to query for user: %w", txErr)
|
||||||
|
case user.ID == "":
|
||||||
|
return errors.New("invalid user loaded: ID is empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a new access token that expires in 1 hour
|
||||||
|
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
|
||||||
|
if txErr != nil {
|
||||||
|
return fmt.Errorf("failed to generate access token: %w", txErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
queryCtx, queryCancel = context.WithTimeout(cmd.Context(), 10*time.Second)
|
||||||
|
defer queryCancel()
|
||||||
|
txErr = tx.
|
||||||
|
WithContext(queryCtx).
|
||||||
|
Create(oneTimeAccessToken).
|
||||||
|
Error
|
||||||
|
if txErr != nil {
|
||||||
|
return fmt.Errorf("failed to save access token: %w", txErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
queryCtx, queryCancel = context.WithTimeout(ctx, 10*time.Second)
|
// Print the result
|
||||||
defer queryCancel()
|
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
|
||||||
txErr = tx.
|
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
|
||||||
WithContext(queryCtx).
|
|
||||||
Create(oneTimeAccessToken).
|
|
||||||
Error
|
|
||||||
if txErr != nil {
|
|
||||||
return fmt.Errorf("failed to save access token: %w", txErr)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
},
|
||||||
if err != nil {
|
}
|
||||||
return err
|
|
||||||
}
|
func init() {
|
||||||
|
rootCmd.AddCommand(oneTimeAccessTokenCmd)
|
||||||
// Print the result
|
|
||||||
fmt.Printf(`A one-time access token valid for 1 hour has been created for "%s".`+"\n", userArg)
|
|
||||||
fmt.Printf("Use the following URL to sign in once: %s/lc/%s\n", common.EnvConfig.AppURL, oneTimeAccessToken.Token)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
36
backend/internal/cmds/root.go
Normal file
36
backend/internal/cmds/root.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
||||||
|
)
|
||||||
|
|
||||||
|
var rootCmd = &cobra.Command{
|
||||||
|
Use: "pocket-id",
|
||||||
|
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
|
||||||
|
Long: "By default, this command starts the pocket-id server.",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
// Start the server
|
||||||
|
err := bootstrap.Bootstrap(cmd.Context())
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("Failed to run pocket-id", "error", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
func Execute() {
|
||||||
|
// Get a context that is canceled when the application is stopping
|
||||||
|
ctx := signals.SignalContext(context.Background())
|
||||||
|
|
||||||
|
err := rootCmd.ExecuteContext(ctx)
|
||||||
|
if err != nil {
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
backend/internal/cmds/version.go
Normal file
19
backend/internal/cmds/version.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package cmds
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(&cobra.Command{
|
||||||
|
Use: "version",
|
||||||
|
Short: "Print the version number",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
fmt.Println("pocket-id " + common.Version)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,10 +1,16 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/caarlos0/env/v11"
|
"github.com/caarlos0/env/v11"
|
||||||
|
sloggin "github.com/gin-contrib/slog"
|
||||||
_ "github.com/joho/godotenv/autoload"
|
_ "github.com/joho/godotenv/autoload"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -18,73 +24,207 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
DbProviderSqlite DbProvider = "sqlite"
|
DbProviderSqlite DbProvider = "sqlite"
|
||||||
DbProviderPostgres DbProvider = "postgres"
|
DbProviderPostgres DbProvider = "postgres"
|
||||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||||
|
defaultSqliteConnString string = "data/pocket-id.db"
|
||||||
|
AppUrl string = "http://localhost:1411"
|
||||||
)
|
)
|
||||||
|
|
||||||
type EnvConfigSchema struct {
|
type EnvConfigSchema struct {
|
||||||
AppEnv string `env:"APP_ENV"`
|
AppEnv string `env:"APP_ENV"`
|
||||||
|
LogLevel string `env:"LOG_LEVEL"`
|
||||||
AppURL string `env:"APP_URL"`
|
AppURL string `env:"APP_URL"`
|
||||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
DbProvider DbProvider `env:"DB_PROVIDER"`
|
||||||
DbConnectionString string `env:"DB_CONNECTION_STRING"`
|
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||||
UploadPath string `env:"UPLOAD_PATH"`
|
UploadPath string `env:"UPLOAD_PATH"`
|
||||||
KeysPath string `env:"KEYS_PATH"`
|
KeysPath string `env:"KEYS_PATH"`
|
||||||
|
KeysStorage string `env:"KEYS_STORAGE"`
|
||||||
|
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||||
Port string `env:"PORT"`
|
Port string `env:"PORT"`
|
||||||
Host string `env:"HOST"`
|
Host string `env:"HOST"`
|
||||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
UnixSocket string `env:"UNIX_SOCKET"`
|
||||||
|
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||||
|
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||||
|
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||||
|
LogJSON bool `env:"LOG_JSON"`
|
||||||
TrustProxy bool `env:"TRUST_PROXY"`
|
TrustProxy bool `env:"TRUST_PROXY"`
|
||||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||||
|
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||||
|
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||||
}
|
}
|
||||||
|
|
||||||
var EnvConfig = &EnvConfigSchema{
|
var EnvConfig = defaultConfig()
|
||||||
AppEnv: "production",
|
|
||||||
DbProvider: "sqlite",
|
|
||||||
DbConnectionString: "file:data/pocket-id.db?_pragma=journal_mode(WAL)&_pragma=busy_timeout(2500)&_txlock=immediate",
|
|
||||||
UploadPath: "data/uploads",
|
|
||||||
KeysPath: "data/keys",
|
|
||||||
AppURL: "http://localhost:1411",
|
|
||||||
Port: "1411",
|
|
||||||
Host: "0.0.0.0",
|
|
||||||
MaxMindLicenseKey: "",
|
|
||||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
|
||||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
|
||||||
UiConfigDisabled: false,
|
|
||||||
MetricsEnabled: false,
|
|
||||||
TracingEnabled: false,
|
|
||||||
TrustProxy: false,
|
|
||||||
AnalyticsDisabled: false,
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
err := parseEnvConfig()
|
||||||
log.Fatal(err)
|
if err != nil {
|
||||||
|
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: nil,
|
||||||
|
AppURL: AppUrl,
|
||||||
|
Port: "1411",
|
||||||
|
Host: "0.0.0.0",
|
||||||
|
UnixSocket: "",
|
||||||
|
UnixSocketMode: "",
|
||||||
|
MaxMindLicenseKey: "",
|
||||||
|
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||||
|
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||||
|
LocalIPv6Ranges: "",
|
||||||
|
UiConfigDisabled: false,
|
||||||
|
MetricsEnabled: false,
|
||||||
|
TracingEnabled: false,
|
||||||
|
TrustProxy: false,
|
||||||
|
AnalyticsDisabled: false,
|
||||||
|
AllowDowngrade: false,
|
||||||
|
InternalAppURL: "",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseEnvConfig() error {
|
||||||
|
parsers := map[reflect.Type]env.ParserFunc{
|
||||||
|
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
|
||||||
|
return []byte(value), nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := env.ParseWithOptions(&EnvConfig, env.Options{
|
||||||
|
FuncMap: parsers,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("error parsing env config: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = resolveFileBasedEnvVariables(&EnvConfig)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the environment variables
|
// Validate the environment variables
|
||||||
|
EnvConfig.LogLevel = strings.ToLower(EnvConfig.LogLevel)
|
||||||
|
if _, err := sloggin.ParseLevel(EnvConfig.LogLevel); err != nil {
|
||||||
|
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||||
|
}
|
||||||
|
|
||||||
switch EnvConfig.DbProvider {
|
switch EnvConfig.DbProvider {
|
||||||
case DbProviderSqlite:
|
case DbProviderSqlite:
|
||||||
if EnvConfig.DbConnectionString == "" {
|
if EnvConfig.DbConnectionString == "" {
|
||||||
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
EnvConfig.DbConnectionString = defaultSqliteConnString
|
||||||
}
|
}
|
||||||
case DbProviderPostgres:
|
case DbProviderPostgres:
|
||||||
if EnvConfig.DbConnectionString == "" {
|
if EnvConfig.DbConnectionString == "" {
|
||||||
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
log.Fatal("Invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||||
}
|
}
|
||||||
|
|
||||||
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
parsedAppUrl, err := url.Parse(EnvConfig.AppURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("APP_URL is not a valid URL")
|
return errors.New("APP_URL is not a valid URL")
|
||||||
}
|
}
|
||||||
if parsedAppUrl.Path != "" {
|
if parsedAppUrl.Path != "" {
|
||||||
log.Fatal("APP_URL must not contain a path")
|
return errors.New("APP_URL must not contain a path")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
||||||
|
if EnvConfig.InternalAppURL == "" {
|
||||||
|
EnvConfig.InternalAppURL = EnvConfig.AppURL
|
||||||
|
} else {
|
||||||
|
parsedInternalAppUrl, err := url.Parse(EnvConfig.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 EnvConfig.KeysStorage {
|
||||||
|
// KeysStorage defaults to "file" if empty
|
||||||
|
case "":
|
||||||
|
EnvConfig.KeysStorage = "file"
|
||||||
|
case "database":
|
||||||
|
if EnvConfig.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 nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets
|
||||||
|
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
|
||||||
|
val := reflect.ValueOf(config).Elem()
|
||||||
|
typ := val.Type()
|
||||||
|
|
||||||
|
for i := 0; i < val.NumField(); i++ {
|
||||||
|
field := val.Field(i)
|
||||||
|
fieldType := typ.Field(i)
|
||||||
|
|
||||||
|
// Only process string and []byte fields
|
||||||
|
isString := field.Kind() == reflect.String
|
||||||
|
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
|
||||||
|
if !isString && !isByteSlice {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process fields with the "options" tag set to "file"
|
||||||
|
optionsTag := fieldType.Tag.Get("options")
|
||||||
|
if optionsTag != "file" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process fields with the "env" tag
|
||||||
|
envTag := fieldType.Tag.Get("env")
|
||||||
|
if envTag == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
envVarName := envTag
|
||||||
|
if commaIndex := len(envTag); commaIndex > 0 {
|
||||||
|
envVarName = envTag[:commaIndex]
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the file environment variable is not set, skip
|
||||||
|
envVarFileName := envVarName + "_FILE"
|
||||||
|
envVarFileValue := os.Getenv(envVarFileName)
|
||||||
|
if envVarFileValue == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
fileContent, err := os.ReadFile(envVarFileValue)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isString {
|
||||||
|
field.SetString(strings.TrimSpace(string(fileContent)))
|
||||||
|
} else {
|
||||||
|
field.SetBytes(fileContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
327
backend/internal/common/env_config_test.go
Normal file
327
backend/internal/common/env_config_test.go
Normal file
@@ -0,0 +1,327 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseEnvConfig(t *testing.T) {
|
||||||
|
// Store original config to restore later
|
||||||
|
originalConfig := EnvConfig
|
||||||
|
t.Cleanup(func() {
|
||||||
|
EnvConfig = originalConfig
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "postgres")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
||||||
|
t.Setenv("APP_URL", "https://example.com")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "invalid")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "test")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "invalid DB_PROVIDER value")
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail when Postgres DB_CONNECTION_STRING is missing", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "postgres")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "missing required env var 'DB_CONNECTION_STRING' for Postgres")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "€://not-a-valid-url")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "APP_URL is not a valid URL")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail when APP_URL contains path", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000/path")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "APP_URL must not contain a path")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_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")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "file", EnvConfig.KeysStorage)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
t.Setenv("KEYS_STORAGE", "database")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
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) {
|
||||||
|
validStorageTypes := []string{"file", "database"}
|
||||||
|
|
||||||
|
for _, storage := range validStorageTypes {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
t.Setenv("KEYS_STORAGE", storage)
|
||||||
|
if storage == "database" {
|
||||||
|
t.Setenv("ENCRYPTION_KEY", "test-key")
|
||||||
|
}
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, storage, EnvConfig.KeysStorage)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should fail with invalid KEYS_STORAGE value", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
t.Setenv("KEYS_STORAGE", "invalid")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "sqlite")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||||
|
t.Setenv("APP_URL", "http://localhost:3000")
|
||||||
|
t.Setenv("UI_CONFIG_DISABLED", "true")
|
||||||
|
t.Setenv("METRICS_ENABLED", "true")
|
||||||
|
t.Setenv("TRACING_ENABLED", "false")
|
||||||
|
t.Setenv("TRUST_PROXY", "true")
|
||||||
|
t.Setenv("ANALYTICS_DISABLED", "false")
|
||||||
|
|
||||||
|
err := parseEnvConfig()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, EnvConfig.UiConfigDisabled)
|
||||||
|
assert.True(t, EnvConfig.MetricsEnabled)
|
||||||
|
assert.False(t, EnvConfig.TracingEnabled)
|
||||||
|
assert.True(t, EnvConfig.TrustProxy)
|
||||||
|
assert.False(t, EnvConfig.AnalyticsDisabled)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should parse string environment variables correctly", func(t *testing.T) {
|
||||||
|
EnvConfig = defaultConfig()
|
||||||
|
t.Setenv("DB_PROVIDER", "postgres")
|
||||||
|
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
||||||
|
t.Setenv("APP_URL", "https://prod.example.com")
|
||||||
|
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("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, "/custom/uploads", EnvConfig.UploadPath)
|
||||||
|
assert.Equal(t, "8080", EnvConfig.Port)
|
||||||
|
assert.Equal(t, "127.0.0.1", EnvConfig.Host)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveFileBasedEnvVariables(t *testing.T) {
|
||||||
|
// Create temporary directory for test files
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Create test files
|
||||||
|
encryptionKeyFile := tempDir + "/encryption_key.txt"
|
||||||
|
encryptionKeyContent := "test-encryption-key-123"
|
||||||
|
err := os.WriteFile(encryptionKeyFile, []byte(encryptionKeyContent), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
dbConnFile := tempDir + "/db_connection.txt"
|
||||||
|
dbConnContent := "postgres://user:pass@localhost/testdb"
|
||||||
|
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Create a binary file for testing binary data handling
|
||||||
|
binaryKeyFile := tempDir + "/binary_key.bin"
|
||||||
|
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10}
|
||||||
|
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
t.Run("should read file content for fields with options:file tag", func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
// Set environment variables pointing to files
|
||||||
|
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
||||||
|
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify file contents were read correctly
|
||||||
|
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
||||||
|
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should skip fields without options:file tag", func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
originalAppURL := config.AppURL
|
||||||
|
|
||||||
|
// Set a file for a field that doesn't have options:file tag
|
||||||
|
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// AppURL should remain unchanged
|
||||||
|
assert.Equal(t, originalAppURL, config.AppURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should skip non-string fields", func(t *testing.T) {
|
||||||
|
// This test verifies that non-string fields are skipped
|
||||||
|
// We test this indirectly by ensuring the function doesn't error
|
||||||
|
// when processing the actual EnvConfigSchema which has bool fields
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
originalEncryptionKey := config.EncryptionKey
|
||||||
|
|
||||||
|
// Don't set ENCRYPTION_KEY_FILE environment variable
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// EncryptionKey should remain unchanged
|
||||||
|
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
// Set multiple file environment variables
|
||||||
|
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
||||||
|
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// All should be resolved correctly
|
||||||
|
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
||||||
|
assert.Equal(t, dbConnContent, config.DbConnectionString)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
// Set both file and non-file environment variables
|
||||||
|
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// File-based should be resolved, others should remain as set by env parser
|
||||||
|
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
|
||||||
|
assert.Equal(t, "http://localhost:1411", config.AppURL)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("should handle binary data correctly", func(t *testing.T) {
|
||||||
|
config := defaultConfig()
|
||||||
|
|
||||||
|
// Set environment variable pointing to binary file
|
||||||
|
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
|
||||||
|
|
||||||
|
err := resolveFileBasedEnvVariables(&config)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify binary data was read correctly without corruption
|
||||||
|
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -65,11 +65,23 @@ type OidcClientSecretInvalidError struct{}
|
|||||||
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcClientAssertionInvalidError struct{}
|
||||||
|
|
||||||
|
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
|
||||||
|
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type OidcInvalidAuthorizationCodeError struct{}
|
type OidcInvalidAuthorizationCodeError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
|
type OidcMissingCallbackURLError struct{}
|
||||||
|
|
||||||
|
func (e *OidcMissingCallbackURLError) Error() string {
|
||||||
|
return "unable to detect callback url, it might be necessary for an admin to fix this"
|
||||||
|
}
|
||||||
|
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
|
||||||
|
|
||||||
type OidcInvalidCallbackURLError struct{}
|
type OidcInvalidCallbackURLError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidCallbackURLError) Error() string {
|
func (e *OidcInvalidCallbackURLError) Error() string {
|
||||||
@@ -156,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
|
|||||||
}
|
}
|
||||||
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||||
|
|
||||||
type AccountEditNotAllowedError struct{}
|
|
||||||
|
|
||||||
func (e *AccountEditNotAllowedError) Error() string {
|
|
||||||
return "You are not allowed to edit your account"
|
|
||||||
}
|
|
||||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
|
||||||
|
|
||||||
type OidcInvalidCodeVerifierError struct{}
|
type OidcInvalidCodeVerifierError struct{}
|
||||||
|
|
||||||
func (e *OidcInvalidCodeVerifierError) Error() string {
|
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||||
@@ -344,3 +349,32 @@ func (e *OidcAuthorizationPendingError) Error() string {
|
|||||||
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||||
return http.StatusBadRequest
|
return http.StatusBadRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReauthenticationRequiredError struct{}
|
||||||
|
|
||||||
|
func (e *ReauthenticationRequiredError) Error() string {
|
||||||
|
return "reauthentication required"
|
||||||
|
}
|
||||||
|
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpenSignupDisabledError struct{}
|
||||||
|
|
||||||
|
func (e *OpenSignupDisabledError) Error() string {
|
||||||
|
return "Open user signup is not enabled"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *OpenSignupDisabledError) HttpStatusCode() int {
|
||||||
|
return http.StatusForbidden
|
||||||
|
}
|
||||||
|
|
||||||
|
type ClientIdAlreadyExistsError struct{}
|
||||||
|
|
||||||
|
func (e *ClientIdAlreadyExistsError) Error() string {
|
||||||
|
return "Client ID already in use"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *ClientIdAlreadyExistsError) HttpStatusCode() int {
|
||||||
|
return http.StatusBadRequest
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
|
// Name is the name of the application
|
||||||
|
const Name = "pocket-id"
|
||||||
|
|
||||||
// Version contains the Pocket ID version.
|
// Version contains the Pocket ID version.
|
||||||
//
|
//
|
||||||
// It can be set at build time using -ldflags.
|
// It can be set at build time using -ldflags.
|
||||||
|
|||||||
@@ -38,10 +38,10 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
|||||||
// @Summary List API keys
|
// @Summary List API keys
|
||||||
// @Description Get a paginated list of API keys belonging to the current user
|
// @Description Get a paginated list of API keys belonging to the current user
|
||||||
// @Tags API Keys
|
// @Tags API Keys
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||||
// @Router /api/api-keys [get]
|
// @Router /api/api-keys [get]
|
||||||
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||||
@@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
|||||||
userID := ctx.GetString("userID")
|
userID := ctx.GetString("userID")
|
||||||
|
|
||||||
var input dto.ApiKeyCreateDto
|
var input dto.ApiKeyCreateDto
|
||||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
|
||||||
_ = ctx.Error(err)
|
_ = ctx.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -57,7 +58,6 @@ type AppConfigController struct {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||||
// @Failure 500 {object} object "{"error": "error message"}"
|
|
||||||
// @Router /application-configuration [get]
|
// @Router /application-configuration [get]
|
||||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||||
configuration := acc.appConfigService.ListAppConfig(false)
|
configuration := acc.appConfigService.ListAppConfig(false)
|
||||||
@@ -85,7 +85,6 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} dto.AppConfigVariableDto
|
// @Success 200 {array} dto.AppConfigVariableDto
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /application-configuration/all [get]
|
// @Router /application-configuration/all [get]
|
||||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||||
configuration := acc.appConfigService.ListAppConfig(true)
|
configuration := acc.appConfigService.ListAppConfig(true)
|
||||||
@@ -107,11 +106,10 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||||
// @Success 200 {array} dto.AppConfigVariableDto
|
// @Success 200 {array} dto.AppConfigVariableDto
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration [put]
|
// @Router /api/application-configuration [put]
|
||||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||||
var input dto.AppConfigUpdateDto
|
var input dto.AppConfigUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -164,7 +162,6 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
|||||||
// @Tags Application Configuration
|
// @Tags Application Configuration
|
||||||
// @Produce image/x-icon
|
// @Produce image/x-icon
|
||||||
// @Success 200 {file} binary "Favicon image"
|
// @Success 200 {file} binary "Favicon image"
|
||||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
|
||||||
// @Router /api/application-configuration/favicon [get]
|
// @Router /api/application-configuration/favicon [get]
|
||||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||||
acc.getImage(c, "favicon", "ico")
|
acc.getImage(c, "favicon", "ico")
|
||||||
@@ -177,7 +174,6 @@ func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
|||||||
// @Produce image/png
|
// @Produce image/png
|
||||||
// @Produce image/jpeg
|
// @Produce image/jpeg
|
||||||
// @Success 200 {file} binary "Background image"
|
// @Success 200 {file} binary "Background image"
|
||||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
|
||||||
// @Router /api/application-configuration/background-image [get]
|
// @Router /api/application-configuration/background-image [get]
|
||||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
||||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
||||||
@@ -192,7 +188,6 @@ func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
|||||||
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
// @Param light query boolean false "Light mode logo (true) or dark mode logo (false)"
|
||||||
// @Param file formData file true "Logo image file"
|
// @Param file formData file true "Logo image file"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/logo [put]
|
// @Router /api/application-configuration/logo [put]
|
||||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||||
dbConfig := acc.appConfigService.GetDbConfig()
|
dbConfig := acc.appConfigService.GetDbConfig()
|
||||||
@@ -218,7 +213,6 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
|||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param file formData file true "Favicon file (.ico)"
|
// @Param file formData file true "Favicon file (.ico)"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/favicon [put]
|
// @Router /api/application-configuration/favicon [put]
|
||||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
@@ -242,7 +236,6 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
|||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param file formData file true "Background image file"
|
// @Param file formData file true "Background image file"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/background-image [put]
|
// @Router /api/application-configuration/background-image [put]
|
||||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
||||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
||||||
@@ -255,6 +248,8 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
|||||||
mimeType := utils.GetImageMimeType(imageType)
|
mimeType := utils.GetImageMimeType(imageType)
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
c.Header("Content-Type", mimeType)
|
||||||
|
|
||||||
|
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
|
||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +275,6 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
|||||||
// @Description Manually trigger LDAP synchronization
|
// @Description Manually trigger LDAP synchronization
|
||||||
// @Tags Application Configuration
|
// @Tags Application Configuration
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/sync-ldap [post]
|
// @Router /api/application-configuration/sync-ldap [post]
|
||||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||||
err := acc.ldapService.SyncAll(c.Request.Context())
|
err := acc.ldapService.SyncAll(c.Request.Context())
|
||||||
@@ -297,7 +291,6 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
|||||||
// @Description Send a test email to verify email configuration
|
// @Description Send a test email to verify email configuration
|
||||||
// @Tags Application Configuration
|
// @Tags Application Configuration
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/application-configuration/test-email [post]
|
// @Router /api/application-configuration/test-email [post]
|
||||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||||
userID := c.GetString("userID")
|
userID := c.GetString("userID")
|
||||||
|
|||||||
@@ -34,10 +34,10 @@ type AuditLogController struct {
|
|||||||
// @Summary List audit logs
|
// @Summary List audit logs
|
||||||
// @Description Get a paginated list of audit logs for the current user
|
// @Description Get a paginated list of audit logs for the current user
|
||||||
// @Tags Audit Logs
|
// @Tags Audit Logs
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
// @Router /api/audit-logs [get]
|
// @Router /api/audit-logs [get]
|
||||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||||
@@ -82,13 +82,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
|||||||
// @Summary List all audit logs
|
// @Summary List all audit logs
|
||||||
// @Description Get a paginated list of all audit logs (admin only)
|
// @Description Get a paginated list of all audit logs (admin only)
|
||||||
// @Tags Audit Logs
|
// @Tags Audit Logs
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Param user_id query string false "Filter by user ID"
|
// @Param filters[userId] query string false "Filter by user ID"
|
||||||
// @Param event query string false "Filter by event type"
|
// @Param filters[event] query string false "Filter by event type"
|
||||||
// @Param client_name query string false "Filter by client name"
|
// @Param filters[clientName] query string false "Filter by client name"
|
||||||
|
// @Param filters[location] query string false "Filter by location type (external or internal)"
|
||||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||||
// @Router /api/audit-logs/all [get]
|
// @Router /api/audit-logs/all [get]
|
||||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||||
|
|||||||
@@ -35,10 +35,6 @@ type CustomClaimController struct {
|
|||||||
// @Tags Custom Claims
|
// @Tags Custom Claims
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Success 200 {array} string "List of suggested custom claim names"
|
// @Success 200 {array} string "List of suggested custom claim names"
|
||||||
// @Failure 401 {object} object "Unauthorized"
|
|
||||||
// @Failure 403 {object} object "Forbidden"
|
|
||||||
// @Failure 500 {object} object "Internal server error"
|
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/custom-claims/suggestions [get]
|
// @Router /api/custom-claims/suggestions [get]
|
||||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
||||||
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
|
claims, err := ccc.customClaimService.GetSuggestions(c.Request.Context())
|
||||||
@@ -63,7 +59,7 @@ func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.Context) {
|
|||||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Context) {
|
||||||
var input []dto.CustomClaimCreateDto
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -93,12 +89,11 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
|||||||
// @Param userGroupId path string true "User Group ID"
|
// @Param userGroupId path string true "User Group ID"
|
||||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
||||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||||
var input []dto.CustomClaimCreateDto
|
var input []dto.CustomClaimCreateDto
|
||||||
|
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
|
|||||||
testController := &TestController{TestService: testService}
|
testController := &TestController{TestService: testService}
|
||||||
|
|
||||||
group.POST("/test/reset", testController.resetAndSeedHandler)
|
group.POST("/test/reset", testController.resetAndSeedHandler)
|
||||||
|
group.POST("/test/refreshtoken", testController.signRefreshToken)
|
||||||
|
|
||||||
|
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
|
||||||
|
group.POST("/externalidp/sign", testController.externalIdPSignToken)
|
||||||
}
|
}
|
||||||
|
|
||||||
type TestController struct {
|
type TestController struct {
|
||||||
@@ -21,19 +25,31 @@ type TestController struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||||
|
var baseURL string
|
||||||
|
if c.Request.TLS != nil {
|
||||||
|
baseURL = "https://" + c.Request.Host
|
||||||
|
} else {
|
||||||
|
baseURL = "http://" + c.Request.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
skipLdap := c.Query("skip-ldap") == "true"
|
||||||
|
skipSeed := c.Query("skip-seed") == "true"
|
||||||
|
|
||||||
if err := tc.TestService.ResetDatabase(); err != nil {
|
if err := tc.TestService.ResetDatabase(); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
if !skipSeed {
|
||||||
_ = c.Error(err)
|
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||||
return
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||||
@@ -41,17 +57,71 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
if !skipLdap {
|
||||||
_ = c.Error(err)
|
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
||||||
return
|
_ = c.Error(err)
|
||||||
}
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
tc.TestService.SetJWTKeys()
|
tc.TestService.SetJWTKeys()
|
||||||
|
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
|
||||||
|
jwks, err := tc.TestService.GetExternalIdPJWKS()
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, jwks)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
Aud string `json:"aud"`
|
||||||
|
Iss string `json:"iss"`
|
||||||
|
Sub string `json:"sub"`
|
||||||
|
}
|
||||||
|
err := c.ShouldBindJSON(&input)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.WriteString(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tc *TestController) signRefreshToken(c *gin.Context) {
|
||||||
|
var input struct {
|
||||||
|
UserID string `json:"user"`
|
||||||
|
ClientID string `json:"client"`
|
||||||
|
RefreshToken string `json:"rt"`
|
||||||
|
}
|
||||||
|
err := c.ShouldBindJSON(&input)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Writer.WriteString(token)
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,19 +2,20 @@ package controller
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewOidcController creates a new controller for OIDC related endpoints
|
// NewOidcController creates a new controller for OIDC related endpoints
|
||||||
@@ -48,9 +49,19 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||||
|
|
||||||
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
||||||
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
||||||
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
||||||
|
|
||||||
|
group.GET("/oidc/users/me/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 {
|
type OidcController struct {
|
||||||
@@ -66,7 +77,6 @@ type OidcController struct {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
||||||
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/authorize [post]
|
// @Router /api/oidc/authorize [post]
|
||||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizeOidcClientRequestDto
|
var input dto.AuthorizeOidcClientRequestDto
|
||||||
@@ -84,6 +94,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
response := dto.AuthorizeOidcClientResponseDto{
|
response := dto.AuthorizeOidcClientResponseDto{
|
||||||
Code: code,
|
Code: code,
|
||||||
CallbackURL: callbackURL,
|
CallbackURL: callbackURL,
|
||||||
|
Issuer: common.EnvConfig.AppURL,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
@@ -97,7 +108,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
||||||
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/authorization-required [post]
|
// @Router /api/oidc/authorization-required [post]
|
||||||
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
||||||
var input dto.AuthorizationRequiredDto
|
var input dto.AuthorizationRequiredDto
|
||||||
@@ -121,11 +131,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
||||||
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
|
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
|
||||||
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
|
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
|
||||||
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
|
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
|
||||||
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
|
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
|
||||||
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
|
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
|
||||||
|
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
|
||||||
|
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
|
||||||
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
|
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
|
||||||
// @Router /api/oidc/token [post]
|
// @Router /api/oidc/token [post]
|
||||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||||
@@ -195,7 +207,7 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
|
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -224,7 +236,6 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
|||||||
// @Description End user session and handle OIDC logout
|
// @Description End user session and handle OIDC logout
|
||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Accept application/x-www-form-urlencoded
|
// @Accept application/x-www-form-urlencoded
|
||||||
// @Produce html
|
|
||||||
// @Param id_token_hint query string false "ID token"
|
// @Param id_token_hint query string false "ID token"
|
||||||
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
||||||
// @Param state query string false "State parameter to include in the redirect"
|
// @Param state query string false "State parameter to include in the redirect"
|
||||||
@@ -251,7 +262,7 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
|||||||
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
|
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
||||||
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
|
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
|
||||||
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -304,9 +315,21 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
|
|||||||
// find valid tokens) while still allowing it to be used by an application that is
|
// find valid tokens) while still allowing it to be used by an application that is
|
||||||
// supposed to interact with our IdP (since that needs to have a client_id
|
// supposed to interact with our IdP (since that needs to have a client_id
|
||||||
// and client_secret anyway).
|
// and client_secret anyway).
|
||||||
clientID, clientSecret, _ := c.Request.BasicAuth()
|
var (
|
||||||
|
creds service.ClientAuthCredentials
|
||||||
|
ok bool
|
||||||
|
)
|
||||||
|
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
// If there's no basic auth, check if we have a bearer token
|
||||||
|
bearer, ok := utils.BearerAuth(c.Request)
|
||||||
|
if ok {
|
||||||
|
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
|
||||||
|
creds.ClientAssertion = bearer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), clientID, clientSecret, input.Token)
|
response, err := oc.oidcService.IntrospectToken(c.Request.Context(), creds, input.Token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -348,7 +371,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id} [get]
|
// @Router /api/oidc/clients/{id} [get]
|
||||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||||
clientId := c.Param("id")
|
clientId := c.Param("id")
|
||||||
@@ -360,12 +382,12 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
|
|
||||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||||
err = dto.MapStruct(client, &clientDto)
|
err = dto.MapStruct(client, &clientDto)
|
||||||
if err == nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusOK, clientDto)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
_ = c.Error(err)
|
c.JSON(http.StatusOK, clientDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listClientsHandler godoc
|
// listClientsHandler godoc
|
||||||
@@ -373,12 +395,11 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
|||||||
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param search query string false "Search term to filter clients by name"
|
// @Param search query string false "Search term to filter clients by name"
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("name")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
|
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients [get]
|
// @Router /api/oidc/clients [get]
|
||||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||||
searchTerm := c.Query("search")
|
searchTerm := c.Query("search")
|
||||||
@@ -424,7 +445,6 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||||
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients [post]
|
// @Router /api/oidc/clients [post]
|
||||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientCreateDto
|
||||||
@@ -454,7 +474,6 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id} [delete]
|
// @Router /api/oidc/clients/{id} [delete]
|
||||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
|
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
|
||||||
@@ -473,12 +492,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
|||||||
// @Accept json
|
// @Accept json
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
// @Param client body dto.OidcClientUpdateDto true "Client information"
|
||||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id} [put]
|
// @Router /api/oidc/clients/{id} [put]
|
||||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||||
var input dto.OidcClientCreateDto
|
var input dto.OidcClientUpdateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -506,7 +524,6 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/secret [post]
|
// @Router /api/oidc/clients/{id}/secret [post]
|
||||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||||
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
|
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
|
||||||
@@ -535,6 +552,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
|
||||||
|
|
||||||
c.Header("Content-Type", mimeType)
|
c.Header("Content-Type", mimeType)
|
||||||
c.File(imagePath)
|
c.File(imagePath)
|
||||||
}
|
}
|
||||||
@@ -545,9 +564,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Accept multipart/form-data
|
// @Accept multipart/form-data
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
|
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/logo [post]
|
// @Router /api/oidc/clients/{id}/logo [post]
|
||||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||||
file, err := c.FormFile("file")
|
file, err := c.FormFile("file")
|
||||||
@@ -571,7 +589,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
|||||||
// @Tags OIDC
|
// @Tags OIDC
|
||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/logo [delete]
|
// @Router /api/oidc/clients/{id}/logo [delete]
|
||||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||||
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
|
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
|
||||||
@@ -592,7 +609,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
|||||||
// @Param id path string true "Client ID"
|
// @Param id path string true "Client ID"
|
||||||
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||||
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
||||||
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||||
var input dto.OidcUpdateAllowedUserGroupsDto
|
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||||
@@ -637,6 +653,114 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response)
|
c.JSON(http.StatusOK, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listOwnAuthorizedClientsHandler godoc
|
||||||
|
// @Summary List authorized clients for current user
|
||||||
|
// @Description Get a paginated list of OIDC clients that the current user has authorized
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||||
|
// @Router /api/oidc/users/me/authorized-clients [get]
|
||||||
|
func (oc *OidcController) listOwnAuthorizedClientsHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
oc.listAuthorizedClients(c, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listAuthorizedClientsHandler godoc
|
||||||
|
// @Summary List authorized clients for a user
|
||||||
|
// @Description Get a paginated list of OIDC clients that a specific user has authorized
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param id path string true "User ID"
|
||||||
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.AuthorizedOidcClientDto]
|
||||||
|
// @Router /api/oidc/users/{id}/authorized-clients [get]
|
||||||
|
func (oc *OidcController) listAuthorizedClientsHandler(c *gin.Context) {
|
||||||
|
userID := c.Param("id")
|
||||||
|
oc.listAuthorizedClients(c, userID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (oc *OidcController) listAuthorizedClients(c *gin.Context, userID string) {
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authorizedClients, pagination, err := oc.oidcService.ListAuthorizedClients(c.Request.Context(), userID, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the clients to DTOs
|
||||||
|
var authorizedClientsDto []dto.AuthorizedOidcClientDto
|
||||||
|
if err := dto.MapStructList(authorizedClients, &authorizedClientsDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.AuthorizedOidcClientDto]{
|
||||||
|
Data: authorizedClientsDto,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// revokeOwnClientAuthorizationHandler godoc
|
||||||
|
// @Summary Revoke authorization for an OIDC client
|
||||||
|
// @Description Revoke the authorization for a specific OIDC client for the current user
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param clientId path string true "Client ID to revoke authorization for"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/oidc/users/me/authorized-clients/{clientId} [delete]
|
||||||
|
func (oc *OidcController) revokeOwnClientAuthorizationHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("clientId")
|
||||||
|
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
err := oc.oidcService.RevokeAuthorizedClient(c.Request.Context(), userID, clientID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listOwnAccessibleClientsHandler godoc
|
||||||
|
// @Summary List accessible OIDC clients for current user
|
||||||
|
// @Description Get a list of OIDC clients that the current user can access
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.AccessibleOidcClientDto]
|
||||||
|
// @Router /api/oidc/users/me/clients [get]
|
||||||
|
func (oc *OidcController) listOwnAccessibleClientsHandler(c *gin.Context) {
|
||||||
|
userID := c.GetString("userID")
|
||||||
|
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clients, pagination, err := oc.oidcService.ListAccessibleOidcClients(c.Request.Context(), userID, sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.AccessibleOidcClientDto]{
|
||||||
|
Data: clients,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
||||||
userCode := c.Query("code")
|
userCode := c.Query("code")
|
||||||
if userCode == "" {
|
if userCode == "" {
|
||||||
@@ -672,3 +796,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, deviceCodeInfo)
|
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getClientPreviewHandler godoc
|
||||||
|
// @Summary Preview OIDC client data for user
|
||||||
|
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
|
||||||
|
// @Tags OIDC
|
||||||
|
// @Produce json
|
||||||
|
// @Param id path string true "Client ID"
|
||||||
|
// @Param userId path string true "User ID to preview data for"
|
||||||
|
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
|
||||||
|
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
|
||||||
|
// @Security BearerAuth
|
||||||
|
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
|
||||||
|
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||||
|
clientID := c.Param("id")
|
||||||
|
userID := c.Param("userId")
|
||||||
|
scopes := c.Query("scopes")
|
||||||
|
|
||||||
|
if clientID == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if userID == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if scopes == "" {
|
||||||
|
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, preview)
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
@@ -15,6 +14,11 @@ import (
|
|||||||
"golang.org/x/time/rate"
|
"golang.org/x/time/rate"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||||
|
defaultSignupTokenDuration = time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
// NewUserController creates a new controller for user management endpoints
|
// NewUserController creates a new controller for user management endpoints
|
||||||
// @Summary User management controller
|
// @Summary User management controller
|
||||||
// @Description Initializes all user-related API endpoints
|
// @Description Initializes all user-related API endpoints
|
||||||
@@ -45,11 +49,17 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
|||||||
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler)
|
||||||
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
||||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||||
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
|
||||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
|
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
|
||||||
|
|
||||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||||
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler)
|
||||||
|
|
||||||
|
group.POST("/signup-tokens", authMiddleware.Add(), uc.createSignupTokenHandler)
|
||||||
|
group.GET("/signup-tokens", authMiddleware.Add(), uc.listSignupTokensHandler)
|
||||||
|
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), uc.deleteSignupTokenHandler)
|
||||||
|
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), uc.signupHandler)
|
||||||
|
group.POST("/signup/setup", uc.signUpInitialAdmin)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserController struct {
|
type UserController struct {
|
||||||
@@ -86,10 +96,10 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
|||||||
// @Description Get a paginated list of users with optional search and sorting
|
// @Description Get a paginated list of users with optional search and sorting
|
||||||
// @Tags Users
|
// @Tags Users
|
||||||
// @Param search query string false "Search term to filter users"
|
// @Param search query string false "Search term to filter users"
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
||||||
// @Router /api/users [get]
|
// @Router /api/users [get]
|
||||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||||
@@ -188,7 +198,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
|||||||
// @Router /api/users [post]
|
// @Router /api/users [post]
|
||||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -228,10 +238,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
|||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/users/me [put]
|
// @Router /api/users/me [put]
|
||||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||||
if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
|
|
||||||
_ = c.Error(&common.AccountEditNotAllowedError{})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
uc.updateUser(c, true)
|
uc.updateUser(c, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,10 +261,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
|||||||
defer picture.Close()
|
defer picture.Close()
|
||||||
}
|
}
|
||||||
|
|
||||||
_, ok := c.GetQuery("skipCache")
|
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
|
||||||
if !ok {
|
|
||||||
c.Header("Cache-Control", "public, max-age=900")
|
|
||||||
}
|
|
||||||
|
|
||||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||||
}
|
}
|
||||||
@@ -333,10 +336,17 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var ttl time.Duration
|
||||||
if own {
|
if own {
|
||||||
input.UserID = c.GetString("userID")
|
input.UserID = c.GetString("userID")
|
||||||
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
|
} else {
|
||||||
|
ttl = input.TTL.Duration
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
|
}
|
||||||
}
|
}
|
||||||
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
|
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -380,7 +390,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
// @Router /api/one-time-access-email [post]
|
// @Router /api/one-time-access-email [post]
|
||||||
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
||||||
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -413,7 +423,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
|||||||
|
|
||||||
userID := c.Param("id")
|
userID := c.Param("id")
|
||||||
|
|
||||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
|
ttl := input.TTL.Duration
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultOneTimeAccessTokenDuration
|
||||||
|
}
|
||||||
|
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -448,14 +462,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
// getSetupAccessTokenHandler godoc
|
// signUpInitialAdmin godoc
|
||||||
// @Summary Setup initial admin
|
// @Summary Sign up initial admin user
|
||||||
// @Description Generate setup access token for initial admin user configuration
|
// @Description Sign up and generate setup access token for initial admin user
|
||||||
// @Tags Users
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param body body dto.SignUpDto true "User information"
|
||||||
// @Success 200 {object} dto.UserDto
|
// @Success 200 {object} dto.UserDto
|
||||||
// @Router /api/one-time-access-token/setup [post]
|
// @Router /api/signup/setup [post]
|
||||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
func (uc *UserController) signUpInitialAdmin(c *gin.Context) {
|
||||||
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
|
var input dto.SignUpDto
|
||||||
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, token, err := uc.userService.SignUpInitialAdmin(c.Request.Context(), input)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
@@ -503,10 +526,138 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, userDto)
|
c.JSON(http.StatusOK, userDto)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// createSignupTokenHandler godoc
|
||||||
|
// @Summary Create signup token
|
||||||
|
// @Description Create a new signup token that allows user registration
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param token body dto.SignupTokenCreateDto true "Signup token information"
|
||||||
|
// @Success 201 {object} dto.SignupTokenDto
|
||||||
|
// @Router /api/signup-tokens [post]
|
||||||
|
func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
|
||||||
|
var input dto.SignupTokenCreateDto
|
||||||
|
if err := c.ShouldBindJSON(&input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl := input.TTL.Duration
|
||||||
|
if ttl <= 0 {
|
||||||
|
ttl = defaultSignupTokenDuration
|
||||||
|
}
|
||||||
|
|
||||||
|
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenDto dto.SignupTokenDto
|
||||||
|
err = dto.MapStruct(signupToken, &tokenDto)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, tokenDto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listSignupTokensHandler godoc
|
||||||
|
// @Summary List signup tokens
|
||||||
|
// @Description Get a paginated list of signup tokens
|
||||||
|
// @Tags Users
|
||||||
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
|
// @Success 200 {object} dto.Paginated[dto.SignupTokenDto]
|
||||||
|
// @Router /api/signup-tokens [get]
|
||||||
|
func (uc *UserController) listSignupTokensHandler(c *gin.Context) {
|
||||||
|
var sortedPaginationRequest utils.SortedPaginationRequest
|
||||||
|
if err := c.ShouldBindQuery(&sortedPaginationRequest); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
tokens, pagination, err := uc.userService.ListSignupTokens(c.Request.Context(), sortedPaginationRequest)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokensDto []dto.SignupTokenDto
|
||||||
|
if err := dto.MapStructList(tokens, &tokensDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, dto.Paginated[dto.SignupTokenDto]{
|
||||||
|
Data: tokensDto,
|
||||||
|
Pagination: pagination,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// deleteSignupTokenHandler godoc
|
||||||
|
// @Summary Delete signup token
|
||||||
|
// @Description Delete a signup token by ID
|
||||||
|
// @Tags Users
|
||||||
|
// @Param id path string true "Token ID"
|
||||||
|
// @Success 204 "No Content"
|
||||||
|
// @Router /api/signup-tokens/{id} [delete]
|
||||||
|
func (uc *UserController) deleteSignupTokenHandler(c *gin.Context) {
|
||||||
|
tokenID := c.Param("id")
|
||||||
|
|
||||||
|
err := uc.userService.DeleteSignupToken(c.Request.Context(), tokenID)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// signupWithTokenHandler godoc
|
||||||
|
// @Summary Sign up
|
||||||
|
// @Description Create a new user account
|
||||||
|
// @Tags Users
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Param user body dto.SignUpDto true "User information"
|
||||||
|
// @Success 201 {object} dto.SignUpDto
|
||||||
|
// @Router /api/signup [post]
|
||||||
|
func (uc *UserController) signupHandler(c *gin.Context) {
|
||||||
|
var input dto.SignUpDto
|
||||||
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ipAddress := c.ClientIP()
|
||||||
|
userAgent := c.GetHeader("User-Agent")
|
||||||
|
|
||||||
|
user, accessToken, err := uc.userService.SignUp(c.Request.Context(), input, ipAddress, userAgent)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
maxAge := int(uc.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes().Seconds())
|
||||||
|
cookie.AddAccessTokenCookie(c, maxAge, accessToken)
|
||||||
|
|
||||||
|
var userDto dto.UserDto
|
||||||
|
if err := dto.MapStruct(user, &userDto); err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, userDto)
|
||||||
|
}
|
||||||
|
|
||||||
// updateUser is an internal helper method, not exposed as an API endpoint
|
// updateUser is an internal helper method, not exposed as an API endpoint
|
||||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||||
var input dto.UserCreateDto
|
var input dto.UserCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,10 +40,10 @@ type UserGroupController struct {
|
|||||||
// @Description Get a paginated list of user groups with optional search and sorting
|
// @Description Get a paginated list of user groups with optional search and sorting
|
||||||
// @Tags User Groups
|
// @Tags User Groups
|
||||||
// @Param search query string false "Search term to filter user groups by name"
|
// @Param search query string false "Search term to filter user groups by name"
|
||||||
// @Param page query int false "Page number, starting from 1" default(1)
|
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||||
// @Param limit query int false "Number of items per page" default(10)
|
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||||
// @Param sort_column query string false "Column to sort by" default("name")
|
// @Param sort[column] query string false "Column to sort by"
|
||||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||||
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
||||||
// @Router /api/user-groups [get]
|
// @Router /api/user-groups [get]
|
||||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||||
@@ -92,7 +92,6 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id} [get]
|
// @Router /api/user-groups/{id} [get]
|
||||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||||
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
|
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
|
||||||
@@ -118,11 +117,10 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||||
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups [post]
|
// @Router /api/user-groups [post]
|
||||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -151,11 +149,10 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
|||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||||
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id} [put]
|
// @Router /api/user-groups/{id} [put]
|
||||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||||
var input dto.UserGroupCreateDto
|
var input dto.UserGroupCreateDto
|
||||||
if err := c.ShouldBindJSON(&input); err != nil {
|
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||||
_ = c.Error(err)
|
_ = c.Error(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -183,7 +180,6 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
|||||||
// @Produce json
|
// @Produce json
|
||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Success 204 "No Content"
|
// @Success 204 "No Content"
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id} [delete]
|
// @Router /api/user-groups/{id} [delete]
|
||||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||||
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
if err := ugc.UserGroupService.Delete(c.Request.Context(), c.Param("id")); err != nil {
|
||||||
@@ -203,7 +199,6 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
|
|||||||
// @Param id path string true "User Group ID"
|
// @Param id path string true "User Group ID"
|
||||||
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
// @Param users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||||
// @Security BearerAuth
|
|
||||||
// @Router /api/user-groups/{id}/users [put]
|
// @Router /api/user-groups/{id}/users [put]
|
||||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||||
var input dto.UserGroupUpdateUsersDto
|
var input dto.UserGroupUpdateUsersDto
|
||||||
|
|||||||
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/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
||||||
|
|
||||||
|
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
|
||||||
|
|
||||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||||
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
|||||||
cookie.AddAccessTokenCookie(c, 0, "")
|
cookie.AddAccessTokenCookie(c, 0, "")
|
||||||
c.Status(http.StatusNoContent)
|
c.Status(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
|
||||||
|
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(&common.MissingSessionIdError{})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
|
||||||
|
// Try to create a reauthentication token with WebAuthn
|
||||||
|
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||||
|
if err == nil {
|
||||||
|
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// If WebAuthn fails, try to create a reauthentication token with the access token
|
||||||
|
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
|
||||||
|
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
|
||||||
|
if err != nil {
|
||||||
|
_ = c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ package controller
|
|||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
|
|||||||
var err error
|
var err error
|
||||||
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
|
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
|
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
|
||||||
|
os.Exit(1)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||||
@@ -64,25 +67,30 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
|
|||||||
|
|
||||||
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||||
appUrl := common.EnvConfig.AppURL
|
appUrl := common.EnvConfig.AppURL
|
||||||
|
|
||||||
|
internalAppUrl := common.EnvConfig.InternalAppURL
|
||||||
|
|
||||||
alg, err := wkc.jwtService.GetKeyAlg()
|
alg, err := wkc.jwtService.GetKeyAlg()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
||||||
}
|
}
|
||||||
config := map[string]any{
|
config := map[string]any{
|
||||||
"issuer": appUrl,
|
"issuer": appUrl,
|
||||||
"authorization_endpoint": appUrl + "/authorize",
|
"authorization_endpoint": appUrl + "/authorize",
|
||||||
"token_endpoint": appUrl + "/api/oidc/token",
|
"token_endpoint": internalAppUrl + "/api/oidc/token",
|
||||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
"userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
|
||||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
"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",
|
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
"jwks_uri": internalAppUrl + "/.well-known/jwks.json",
|
||||||
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
|
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
|
||||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||||
"response_types_supported": []string{"code", "id_token"},
|
"response_types_supported": []string{"code", "id_token"},
|
||||||
"subject_types_supported": []string{"public"},
|
"subject_types_supported": []string{"public"},
|
||||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||||
|
"authorization_response_iss_parameter_supported": true,
|
||||||
|
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||||
}
|
}
|
||||||
return json.Marshal(config)
|
return json.Marshal(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type ApiKeyCreateDto struct {
|
type ApiKeyCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,min=3,max=50"`
|
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description" unorm:"nfc"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ApiKeyDto struct {
|
type ApiKeyDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Description string `json:"description"`
|
Description *string `json:"description"`
|
||||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|||||||
@@ -12,11 +12,15 @@ type AppConfigVariableDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppConfigUpdateDto struct {
|
type AppConfigUpdateDto struct {
|
||||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||||
|
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||||
|
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||||
|
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||||
|
AccentColor string `json:"accentColor"`
|
||||||
SmtpHost string `json:"smtpHost"`
|
SmtpHost string `json:"smtpHost"`
|
||||||
SmtpPort string `json:"smtpPort"`
|
SmtpPort string `json:"smtpPort"`
|
||||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -9,18 +8,19 @@ type AuditLogDto struct {
|
|||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
|
||||||
Event model.AuditLogEvent `json:"event"`
|
Event string `json:"event"`
|
||||||
IpAddress string `json:"ipAddress"`
|
IpAddress string `json:"ipAddress"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
City string `json:"city"`
|
City string `json:"city"`
|
||||||
Device string `json:"device"`
|
Device string `json:"device"`
|
||||||
UserID string `json:"userID"`
|
UserID string `json:"userID"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Data model.AuditLogData `json:"data"`
|
Data map[string]string `json:"data"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuditLogFilterDto struct {
|
type AuditLogFilterDto struct {
|
||||||
UserID string `form:"filters[userId]"`
|
UserID string `form:"filters[userId]"`
|
||||||
Event string `form:"filters[event]"`
|
Event string `form:"filters[event]"`
|
||||||
ClientName string `form:"filters[clientName]"`
|
ClientName string `form:"filters[clientName]"`
|
||||||
|
Location string `form:"filters[location]"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CustomClaimCreateDto struct {
|
type CustomClaimCreateDto struct {
|
||||||
Key string `json:"key" binding:"required"`
|
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||||
Value string `json:"value" binding:"required"`
|
Value string `json:"value" binding:"required" unorm:"nfc"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,109 +1,27 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"fmt"
|
||||||
"reflect"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
"github.com/jinzhu/copier"
|
||||||
)
|
)
|
||||||
|
|
||||||
// MapStructList maps a list of source structs to a list of destination structs
|
// MapStructList maps a list of source structs to a list of destination structs
|
||||||
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
|
||||||
*destination = make([]D, 0, len(source))
|
*destination = make([]D, len(source))
|
||||||
|
|
||||||
for _, item := range source {
|
for i, item := range source {
|
||||||
var destItem D
|
err = MapStruct(item, &((*destination)[i]))
|
||||||
if err := MapStruct(item, &destItem); err != nil {
|
if err != nil {
|
||||||
return err
|
return fmt.Errorf("failed to map field %d: %w", i, err)
|
||||||
}
|
}
|
||||||
*destination = append(*destination, destItem)
|
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// MapStruct maps a source struct to a destination struct
|
// MapStruct maps a source struct to a destination struct
|
||||||
func MapStruct[S any, D any](source S, destination *D) error {
|
func MapStruct(source any, destination any) error {
|
||||||
// Ensure destination is a non-nil pointer
|
return copier.CopyWithOption(destination, source, copier.Option{
|
||||||
destValue := reflect.ValueOf(destination)
|
DeepCopy: true,
|
||||||
if destValue.Kind() != reflect.Ptr || destValue.IsNil() {
|
})
|
||||||
return errors.New("destination must be a non-nil pointer to a struct")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure source is a struct
|
|
||||||
sourceValue := reflect.ValueOf(source)
|
|
||||||
if sourceValue.Kind() != reflect.Struct {
|
|
||||||
return errors.New("source must be a struct")
|
|
||||||
}
|
|
||||||
|
|
||||||
return mapStructInternal(sourceValue, destValue.Elem())
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapStructInternal(sourceVal reflect.Value, destVal reflect.Value) error {
|
|
||||||
for i := 0; i < destVal.NumField(); i++ {
|
|
||||||
destField := destVal.Field(i)
|
|
||||||
destFieldType := destVal.Type().Field(i)
|
|
||||||
|
|
||||||
if destFieldType.Anonymous {
|
|
||||||
if err := mapStructInternal(sourceVal, destField); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
sourceField := sourceVal.FieldByName(destFieldType.Name)
|
|
||||||
|
|
||||||
if sourceField.IsValid() && destField.CanSet() {
|
|
||||||
if err := mapField(sourceField, destField); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapField(sourceField reflect.Value, destField reflect.Value) error {
|
|
||||||
switch {
|
|
||||||
case sourceField.Type() == destField.Type():
|
|
||||||
destField.Set(sourceField)
|
|
||||||
case sourceField.Kind() == reflect.Slice && destField.Kind() == reflect.Slice:
|
|
||||||
return mapSlice(sourceField, destField)
|
|
||||||
case sourceField.Kind() == reflect.Struct && destField.Kind() == reflect.Struct:
|
|
||||||
return mapStructInternal(sourceField, destField)
|
|
||||||
default:
|
|
||||||
return mapSpecialTypes(sourceField, destField)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapSlice(sourceField reflect.Value, destField reflect.Value) error {
|
|
||||||
if sourceField.Type().Elem() == destField.Type().Elem() {
|
|
||||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
|
||||||
for j := 0; j < sourceField.Len(); j++ {
|
|
||||||
newSlice.Index(j).Set(sourceField.Index(j))
|
|
||||||
}
|
|
||||||
destField.Set(newSlice)
|
|
||||||
} else if sourceField.Type().Elem().Kind() == reflect.Struct && destField.Type().Elem().Kind() == reflect.Struct {
|
|
||||||
newSlice := reflect.MakeSlice(destField.Type(), sourceField.Len(), sourceField.Cap())
|
|
||||||
for j := 0; j < sourceField.Len(); j++ {
|
|
||||||
sourceElem := sourceField.Index(j)
|
|
||||||
destElem := reflect.New(destField.Type().Elem()).Elem()
|
|
||||||
if err := mapStructInternal(sourceElem, destElem); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
newSlice.Index(j).Set(destElem)
|
|
||||||
}
|
|
||||||
destField.Set(newSlice)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func mapSpecialTypes(sourceField reflect.Value, destField reflect.Value) error {
|
|
||||||
if _, ok := sourceField.Interface().(datatype.DateTime); ok {
|
|
||||||
if sourceField.Type() == reflect.TypeOf(datatype.DateTime{}) && destField.Type() == reflect.TypeOf(time.Time{}) {
|
|
||||||
dateValue := sourceField.Interface().(datatype.DateTime)
|
|
||||||
destField.Set(reflect.ValueOf(dateValue.ToTime()))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
197
backend/internal/dto/dto_mapper_test.go
Normal file
197
backend/internal/dto/dto_mapper_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceStruct struct {
|
||||||
|
AString string
|
||||||
|
AStringPtr *string
|
||||||
|
ABool bool
|
||||||
|
ABoolPtr *bool
|
||||||
|
ACustomDateTime datatype.DateTime
|
||||||
|
ACustomDateTimePtr *datatype.DateTime
|
||||||
|
ANilStringPtr *string
|
||||||
|
ASlice []string
|
||||||
|
AMap map[string]int
|
||||||
|
AStruct embeddedStruct
|
||||||
|
AStructPtr *embeddedStruct
|
||||||
|
|
||||||
|
StringPtrToString *string
|
||||||
|
EmptyStringPtrToString *string
|
||||||
|
NilStringPtrToString *string
|
||||||
|
IntToInt64 int
|
||||||
|
AuditLogEventToString model.AuditLogEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
type destStruct struct {
|
||||||
|
AString string
|
||||||
|
AStringPtr *string
|
||||||
|
ABool bool
|
||||||
|
ABoolPtr *bool
|
||||||
|
ACustomDateTime datatype.DateTime
|
||||||
|
ACustomDateTimePtr *datatype.DateTime
|
||||||
|
ANilStringPtr *string
|
||||||
|
ASlice []string
|
||||||
|
AMap map[string]int
|
||||||
|
AStruct embeddedStruct
|
||||||
|
AStructPtr *embeddedStruct
|
||||||
|
|
||||||
|
StringPtrToString string
|
||||||
|
EmptyStringPtrToString string
|
||||||
|
NilStringPtrToString string
|
||||||
|
IntToInt64 int64
|
||||||
|
AuditLogEventToString string
|
||||||
|
}
|
||||||
|
|
||||||
|
type embeddedStruct struct {
|
||||||
|
Foo string
|
||||||
|
Bar int64
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStruct(t *testing.T) {
|
||||||
|
src := sourceStruct{
|
||||||
|
AString: "abcd",
|
||||||
|
AStringPtr: utils.Ptr("xyz"),
|
||||||
|
ABool: true,
|
||||||
|
ABoolPtr: utils.Ptr(false),
|
||||||
|
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
|
||||||
|
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||||
|
ANilStringPtr: nil,
|
||||||
|
ASlice: []string{"a", "b", "c"},
|
||||||
|
AMap: map[string]int{
|
||||||
|
"a": 1,
|
||||||
|
"b": 2,
|
||||||
|
},
|
||||||
|
AStruct: embeddedStruct{
|
||||||
|
Foo: "bar",
|
||||||
|
Bar: 42,
|
||||||
|
},
|
||||||
|
AStructPtr: &embeddedStruct{
|
||||||
|
Foo: "quo",
|
||||||
|
Bar: 111,
|
||||||
|
},
|
||||||
|
|
||||||
|
StringPtrToString: utils.Ptr("foobar"),
|
||||||
|
EmptyStringPtrToString: utils.Ptr(""),
|
||||||
|
NilStringPtrToString: nil,
|
||||||
|
IntToInt64: 99,
|
||||||
|
AuditLogEventToString: model.AuditLogEventAccountCreated,
|
||||||
|
}
|
||||||
|
var dst destStruct
|
||||||
|
err := MapStruct(src, &dst)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
assert.Equal(t, src.AString, dst.AString)
|
||||||
|
_ = assert.NotNil(t, src.AStringPtr) &&
|
||||||
|
assert.Equal(t, *src.AStringPtr, *dst.AStringPtr)
|
||||||
|
assert.Equal(t, src.ABool, dst.ABool)
|
||||||
|
_ = assert.NotNil(t, src.ABoolPtr) &&
|
||||||
|
assert.Equal(t, *src.ABoolPtr, *dst.ABoolPtr)
|
||||||
|
assert.Equal(t, src.ACustomDateTime, dst.ACustomDateTime)
|
||||||
|
_ = assert.NotNil(t, src.ACustomDateTimePtr) &&
|
||||||
|
assert.Equal(t, *src.ACustomDateTimePtr, *dst.ACustomDateTimePtr)
|
||||||
|
assert.Nil(t, dst.ANilStringPtr)
|
||||||
|
assert.Equal(t, src.ASlice, dst.ASlice)
|
||||||
|
assert.Equal(t, src.AMap, dst.AMap)
|
||||||
|
assert.Equal(t, "bar", dst.AStruct.Foo)
|
||||||
|
assert.Equal(t, int64(42), dst.AStruct.Bar)
|
||||||
|
_ = assert.NotNil(t, src.AStructPtr) &&
|
||||||
|
assert.Equal(t, "quo", dst.AStructPtr.Foo) &&
|
||||||
|
assert.Equal(t, int64(111), dst.AStructPtr.Bar)
|
||||||
|
assert.Equal(t, "foobar", dst.StringPtrToString)
|
||||||
|
assert.Empty(t, dst.EmptyStringPtrToString)
|
||||||
|
assert.Empty(t, dst.NilStringPtrToString)
|
||||||
|
assert.Equal(t, int64(99), dst.IntToInt64)
|
||||||
|
assert.Equal(t, "ACCOUNT_CREATED", dst.AuditLogEventToString)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStructList(t *testing.T) {
|
||||||
|
sources := []sourceStruct{
|
||||||
|
{
|
||||||
|
AString: "first",
|
||||||
|
AStringPtr: utils.Ptr("one"),
|
||||||
|
ABool: true,
|
||||||
|
ABoolPtr: utils.Ptr(false),
|
||||||
|
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
|
||||||
|
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||||
|
ASlice: []string{"a", "b"},
|
||||||
|
AMap: map[string]int{
|
||||||
|
"a": 1,
|
||||||
|
"b": 2,
|
||||||
|
},
|
||||||
|
AStruct: embeddedStruct{
|
||||||
|
Foo: "first_struct",
|
||||||
|
Bar: 10,
|
||||||
|
},
|
||||||
|
IntToInt64: 10,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
AString: "second",
|
||||||
|
AStringPtr: utils.Ptr("two"),
|
||||||
|
ABool: false,
|
||||||
|
ABoolPtr: utils.Ptr(true),
|
||||||
|
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
|
||||||
|
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
|
||||||
|
ASlice: []string{"c", "d", "e"},
|
||||||
|
AMap: map[string]int{
|
||||||
|
"c": 3,
|
||||||
|
"d": 4,
|
||||||
|
},
|
||||||
|
AStruct: embeddedStruct{
|
||||||
|
Foo: "second_struct",
|
||||||
|
Bar: 20,
|
||||||
|
},
|
||||||
|
IntToInt64: 20,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
var destinations []destStruct
|
||||||
|
err := MapStructList(sources, &destinations)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, destinations, 2)
|
||||||
|
|
||||||
|
// Verify first element
|
||||||
|
assert.Equal(t, "first", destinations[0].AString)
|
||||||
|
assert.Equal(t, "one", *destinations[0].AStringPtr)
|
||||||
|
assert.True(t, destinations[0].ABool)
|
||||||
|
assert.False(t, *destinations[0].ABoolPtr)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)), destinations[0].ACustomDateTime)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC)), *destinations[0].ACustomDateTimePtr)
|
||||||
|
assert.Equal(t, []string{"a", "b"}, destinations[0].ASlice)
|
||||||
|
assert.Equal(t, map[string]int{"a": 1, "b": 2}, destinations[0].AMap)
|
||||||
|
assert.Equal(t, "first_struct", destinations[0].AStruct.Foo)
|
||||||
|
assert.Equal(t, int64(10), destinations[0].AStruct.Bar)
|
||||||
|
assert.Equal(t, int64(10), destinations[0].IntToInt64)
|
||||||
|
|
||||||
|
// Verify second element
|
||||||
|
assert.Equal(t, "second", destinations[1].AString)
|
||||||
|
assert.Equal(t, "two", *destinations[1].AStringPtr)
|
||||||
|
assert.False(t, destinations[1].ABool)
|
||||||
|
assert.True(t, *destinations[1].ABoolPtr)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)), destinations[1].ACustomDateTime)
|
||||||
|
assert.Equal(t, datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC)), *destinations[1].ACustomDateTimePtr)
|
||||||
|
assert.Equal(t, []string{"c", "d", "e"}, destinations[1].ASlice)
|
||||||
|
assert.Equal(t, map[string]int{"c": 3, "d": 4}, destinations[1].AMap)
|
||||||
|
assert.Equal(t, "second_struct", destinations[1].AStruct.Foo)
|
||||||
|
assert.Equal(t, int64(20), destinations[1].AStruct.Bar)
|
||||||
|
assert.Equal(t, int64(20), destinations[1].IntToInt64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMapStructList_EmptySource(t *testing.T) {
|
||||||
|
var sources []sourceStruct
|
||||||
|
var destinations []destStruct
|
||||||
|
|
||||||
|
err := MapStructList(sources, &destinations)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, destinations)
|
||||||
|
}
|
||||||
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,17 +1,22 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
|
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
|
||||||
type OidcClientMetaDataDto struct {
|
type OidcClientMetaDataDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
HasLogo bool `json:"hasLogo"`
|
HasLogo bool `json:"hasLogo"`
|
||||||
|
LaunchURL *string `json:"launchURL"`
|
||||||
|
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientDto struct {
|
type OidcClientDto struct {
|
||||||
OidcClientMetaDataDto
|
OidcClientMetaDataDto
|
||||||
CallbackURLs []string `json:"callbackURLs"`
|
CallbackURLs []string `json:"callbackURLs"`
|
||||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||||
IsPublic bool `json:"isPublic"`
|
IsPublic bool `json:"isPublic"`
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
PkceEnabled bool `json:"pkceEnabled"`
|
||||||
|
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcClientWithAllowedUserGroupsDto struct {
|
type OidcClientWithAllowedUserGroupsDto struct {
|
||||||
@@ -24,26 +29,47 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
|||||||
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
|
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcClientUpdateDto struct {
|
||||||
|
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||||
|
CallbackURLs []string `json:"callbackURLs" 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 {
|
type OidcClientCreateDto struct {
|
||||||
Name string `json:"name" binding:"required,max=50"`
|
OidcClientUpdateDto
|
||||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
|
||||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
}
|
||||||
IsPublic bool `json:"isPublic"`
|
|
||||||
PkceEnabled bool `json:"pkceEnabled"`
|
type OidcClientCredentialsDto struct {
|
||||||
|
FederatedIdentities []OidcClientFederatedIdentityDto `json:"federatedIdentities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientFederatedIdentityDto struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Audience string `json:"audience,omitempty"`
|
||||||
|
JWKS string `json:"jwks,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientRequestDto struct {
|
type AuthorizeOidcClientRequestDto struct {
|
||||||
ClientID string `json:"clientID" binding:"required"`
|
ClientID string `json:"clientID" binding:"required"`
|
||||||
Scope string `json:"scope" binding:"required"`
|
Scope string `json:"scope" binding:"required"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
Nonce string `json:"nonce"`
|
Nonce string `json:"nonce"`
|
||||||
CodeChallenge string `json:"codeChallenge"`
|
CodeChallenge string `json:"codeChallenge"`
|
||||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||||
|
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizeOidcClientResponseDto struct {
|
type AuthorizeOidcClientResponseDto struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
CallbackURL string `json:"callbackURL"`
|
CallbackURL string `json:"callbackURL"`
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthorizationRequiredDto struct {
|
type AuthorizationRequiredDto struct {
|
||||||
@@ -52,13 +78,16 @@ type AuthorizationRequiredDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcCreateTokensDto struct {
|
type OidcCreateTokensDto struct {
|
||||||
GrantType string `form:"grant_type" binding:"required"`
|
GrantType string `form:"grant_type" binding:"required"`
|
||||||
Code string `form:"code"`
|
Code string `form:"code"`
|
||||||
DeviceCode string `form:"device_code"`
|
DeviceCode string `form:"device_code"`
|
||||||
ClientID string `form:"client_id"`
|
ClientID string `form:"client_id"`
|
||||||
ClientSecret string `form:"client_secret"`
|
ClientSecret string `form:"client_secret"`
|
||||||
CodeVerifier string `form:"code_verifier"`
|
CodeVerifier string `form:"code_verifier"`
|
||||||
RefreshToken string `form:"refresh_token"`
|
RefreshToken string `form:"refresh_token"`
|
||||||
|
ClientAssertion string `form:"client_assertion"`
|
||||||
|
ClientAssertionType string `form:"client_assertion_type"`
|
||||||
|
Resource string `form:"resource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcIntrospectDto struct {
|
type OidcIntrospectDto struct {
|
||||||
@@ -98,9 +127,11 @@ type OidcIntrospectionResponseDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type OidcDeviceAuthorizationRequestDto struct {
|
type OidcDeviceAuthorizationRequestDto struct {
|
||||||
ClientID string `form:"client_id" binding:"required"`
|
ClientID string `form:"client_id" binding:"required"`
|
||||||
Scope string `form:"scope" binding:"required"`
|
Scope string `form:"scope" binding:"required"`
|
||||||
ClientSecret string `form:"client_secret"`
|
ClientSecret string `form:"client_secret"`
|
||||||
|
ClientAssertion string `form:"client_assertion"`
|
||||||
|
ClientAssertionType string `form:"client_assertion_type"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcDeviceAuthorizationResponseDto struct {
|
type OidcDeviceAuthorizationResponseDto struct {
|
||||||
@@ -125,3 +156,20 @@ type DeviceCodeInfoDto struct {
|
|||||||
AuthorizationRequired bool `json:"authorizationRequired"`
|
AuthorizationRequired bool `json:"authorizationRequired"`
|
||||||
Client OidcClientMetaDataDto `json:"client"`
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type AuthorizedOidcClientDto struct {
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Client OidcClientMetaDataDto `json:"client"`
|
||||||
|
LastUsedAt datatype.DateTime `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientPreviewDto struct {
|
||||||
|
IdToken map[string]any `json:"idToken"`
|
||||||
|
AccessToken map[string]any `json:"accessToken"`
|
||||||
|
UserInfo map[string]any `json:"userInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AccessibleOidcClientDto struct {
|
||||||
|
OidcClientMetaDataDto
|
||||||
|
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||||
|
}
|
||||||
|
|||||||
20
backend/internal/dto/signup_token_dto.go
Normal file
20
backend/internal/dto/signup_token_dto.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
import (
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupTokenCreateDto struct {
|
||||||
|
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
|
||||||
|
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SignupTokenDto struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||||
|
UsageLimit int `json:"usageLimit"`
|
||||||
|
UsageCount int `json:"usageCount"`
|
||||||
|
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||||
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
)
|
||||||
|
|
||||||
type UserDto struct {
|
type UserDto struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -17,30 +22,49 @@ type UserDto struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserCreateDto struct {
|
type UserCreateDto struct {
|
||||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||||
LastName string `json:"lastName" binding:"max=50"`
|
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||||
IsAdmin bool `json:"isAdmin"`
|
IsAdmin bool `json:"isAdmin"`
|
||||||
Locale *string `json:"locale"`
|
Locale *string `json:"locale"`
|
||||||
Disabled bool `json:"disabled"`
|
Disabled bool `json:"disabled"`
|
||||||
LdapID string `json:"-"`
|
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 {
|
type OneTimeAccessTokenCreateDto struct {
|
||||||
UserID string `json:"userId"`
|
UserID string `json:"userId"`
|
||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||||
Email string `json:"email" binding:"required,email"`
|
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||||
RedirectPath string `json:"redirectPath"`
|
RedirectPath string `json:"redirectPath"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OneTimeAccessEmailAsAdminDto struct {
|
type OneTimeAccessEmailAsAdminDto struct {
|
||||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type UserUpdateUserGroupDto struct {
|
type UserUpdateUserGroupDto struct {
|
||||||
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
UserGroupIds []string `json:"userGroupIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SignUpDto struct {
|
||||||
|
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||||
|
Email string `json:"email" binding:"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"`
|
||||||
|
}
|
||||||
|
|||||||
89
backend/internal/dto/user_dto_test.go
Normal file
89
backend/internal/dto/user_dto_test.go
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
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",
|
||||||
|
},
|
||||||
|
wantErr: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing username",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Email: "test@example.com",
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "Doe",
|
||||||
|
},
|
||||||
|
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "username contains invalid characters",
|
||||||
|
input: UserCreateDto{
|
||||||
|
Username: "test/ser",
|
||||||
|
Email: "test@example.com",
|
||||||
|
FirstName: "John",
|
||||||
|
LastName: "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",
|
||||||
|
},
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
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: "abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||||
|
},
|
||||||
|
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
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin/binding"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,11 +37,22 @@ type UserGroupDtoWithUserCount struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type UserGroupCreateDto struct {
|
type UserGroupCreateDto struct {
|
||||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
|
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||||
LdapID string `json:"-"`
|
LdapID string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
type UserGroupUpdateUsersDto struct {
|
||||||
UserIDs []string `json:"userIds" binding:"required"`
|
UserIDs []string `json:"userIds" binding:"required"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,87 @@
|
|||||||
package dto
|
package dto
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"log"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin/binding"
|
"github.com/gin-gonic/gin/binding"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||||
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||||
// [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]$")
|
||||||
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
|
|
||||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||||
return matched
|
|
||||||
}
|
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
v := binding.Validator.Engine().(*validator.Validate)
|
||||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
|
||||||
log.Fatalf("Failed to register custom validation: %v", err)
|
// 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 {
|
type WebauthnCredentialUpdateDto struct {
|
||||||
Name string `json:"name" binding:"required,min=1,max=30"`
|
Name string `json:"name" binding:"required,min=1,max=50"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
backoff "github.com/cenkalti/backoff/v5"
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
@@ -14,8 +18,17 @@ import (
|
|||||||
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
|
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
|
||||||
|
|
||||||
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
|
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
|
||||||
jobs := &AnalyticsJob{appConfig: appConfig, httpClient: httpClient}
|
// Skip if analytics are disabled or not in production environment
|
||||||
return s.registerJob(ctx, "SendHeartbeat", "0 0 * * *", jobs.sendHeartbeat, true)
|
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send every 24 hours
|
||||||
|
jobs := &AnalyticsJob{
|
||||||
|
appConfig: appConfig,
|
||||||
|
httpClient: httpClient,
|
||||||
|
}
|
||||||
|
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
type AnalyticsJob struct {
|
type AnalyticsJob struct {
|
||||||
@@ -24,38 +37,50 @@ type AnalyticsJob struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// sendHeartbeat sends a heartbeat to the analytics service
|
// sendHeartbeat sends a heartbeat to the analytics service
|
||||||
func (j *AnalyticsJob) sendHeartbeat(ctx context.Context) error {
|
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
|
||||||
// Skip if analytics are disabled or not in production environment
|
// Skip if analytics are disabled or not in production environment
|
||||||
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
body := struct {
|
body, err := json.Marshal(struct {
|
||||||
Version string `json:"version"`
|
Version string `json:"version"`
|
||||||
InstanceID string `json:"instance_id"`
|
InstanceID string `json:"instance_id"`
|
||||||
}{
|
}{
|
||||||
Version: common.Version,
|
Version: common.Version,
|
||||||
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
|
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
|
||||||
}
|
})
|
||||||
bodyBytes, err := json.Marshal(body)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
|
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewBuffer(bodyBytes))
|
_, err = backoff.Retry(
|
||||||
|
parentCtx,
|
||||||
|
func() (struct{}, error) {
|
||||||
|
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := j.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return struct{}{}, fmt.Errorf("failed to send request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return struct{}{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
return struct{}{}, nil
|
||||||
|
},
|
||||||
|
backoff.WithBackOff(backoff.NewExponentialBackOff()),
|
||||||
|
backoff.WithMaxTries(3),
|
||||||
|
)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to create heartbeat request: %w", err)
|
return fmt.Errorf("heartbeat request failed: %w", err)
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json")
|
|
||||||
resp, err := j.httpClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to send heartbeat request: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return fmt.Errorf("heartbeat request failed with status code: %d", resp.StatusCode)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,10 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"log"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
@@ -18,7 +21,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
|||||||
appConfigService: appConfigService,
|
appConfigService: appConfigService,
|
||||||
}
|
}
|
||||||
|
|
||||||
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys, false)
|
// Send every day at midnight
|
||||||
|
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||||
@@ -29,16 +33,16 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
|||||||
|
|
||||||
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
|
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to list expiring API keys: %v", err)
|
return fmt.Errorf("failed to list expiring API keys: %w", err)
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, key := range apiKeys {
|
for _, key := range apiKeys {
|
||||||
if key.User.Email == "" {
|
if key.User.Email == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil {
|
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||||
log.Printf("Failed to send email for key %s: %v", key.ID, err)
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ package job
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
@@ -14,12 +17,16 @@ import (
|
|||||||
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||||
jobs := &DbCleanupJobs{db: db}
|
jobs := &DbCleanupJobs{db: db}
|
||||||
|
|
||||||
|
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
||||||
|
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
||||||
return errors.Join(
|
return errors.Join(
|
||||||
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions, false),
|
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens, false),
|
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes, false),
|
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||||
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens, false),
|
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||||
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs, false),
|
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||||
|
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,40 +36,99 @@ type DbCleanupJobs struct {
|
|||||||
|
|
||||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||||
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
|
||||||
return j.db.
|
st := j.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
Error
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||||
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||||
return j.db.
|
st := j.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
Error
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearSignupTokens deletes signup tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearSignupTokens(ctx context.Context) error {
|
||||||
|
// Delete tokens that are expired OR have reached their usage limit
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.SignupToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||||
return j.db.
|
st := j.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
Error
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||||
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
|
||||||
return j.db.
|
st := j.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
Error
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
|
||||||
|
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
|
||||||
|
st := j.db.
|
||||||
|
WithContext(ctx).
|
||||||
|
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||||
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClearAuditLogs deletes audit logs older than 90 days
|
// ClearAuditLogs deletes audit logs older than 90 days
|
||||||
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||||
return j.db.
|
st := j.db.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).
|
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
|
||||||
Error
|
if st.Error != nil {
|
||||||
|
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package job
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
@@ -17,7 +19,8 @@ import (
|
|||||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||||
jobs := &FileCleanupJobs{db: db}
|
jobs := &FileCleanupJobs{db: db}
|
||||||
|
|
||||||
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures, false)
|
// Run every 24 hours
|
||||||
|
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileCleanupJobs struct {
|
type FileCleanupJobs struct {
|
||||||
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
|||||||
if _, ok := initialsInUse[initials]; !ok {
|
if _, ok := initialsInUse[initials]; !ok {
|
||||||
filePath := filepath.Join(defaultPicturesDir, filename)
|
filePath := filepath.Join(defaultPicturesDir, filename)
|
||||||
if err := os.Remove(filePath); err != nil {
|
if err := os.Remove(filePath); err != nil {
|
||||||
log.Printf("Failed to delete unused default profile picture %s: %v", filePath, err)
|
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
||||||
} else {
|
} else {
|
||||||
filesDeleted++
|
filesDeleted++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Deleted %d unused default profile pictures", filesDeleted)
|
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
@@ -19,8 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
|||||||
|
|
||||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||||
|
|
||||||
// Register the job to run every day, at 5 minutes past midnight
|
// Run every 24 hours (and right away)
|
||||||
return s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB, true)
|
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package job
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-co-op/gocron/v2"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||||
)
|
)
|
||||||
@@ -15,7 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
|
|||||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||||
|
|
||||||
// Register the job to run every hour
|
// Register the job to run every hour
|
||||||
return s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap, true)
|
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package job
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
|
|
||||||
"github.com/go-co-op/gocron/v2"
|
"github.com/go-co-op/gocron/v2"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
|
|||||||
// Run the scheduler.
|
// Run the scheduler.
|
||||||
// This function blocks until the context is canceled.
|
// This function blocks until the context is canceled.
|
||||||
func (s *Scheduler) Run(ctx context.Context) error {
|
func (s *Scheduler) Run(ctx context.Context) error {
|
||||||
log.Println("Starting job scheduler")
|
slog.Info("Starting job scheduler")
|
||||||
s.scheduler.Start()
|
s.scheduler.Start()
|
||||||
|
|
||||||
// Block until context is canceled
|
// Block until context is canceled
|
||||||
@@ -35,23 +35,36 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
|||||||
|
|
||||||
err := s.scheduler.Shutdown()
|
err := s.scheduler.Shutdown()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WARN] Error shutting down job scheduler: %v", err)
|
slog.Error("Error shutting down job scheduler", slog.Any("error", err))
|
||||||
} else {
|
} else {
|
||||||
log.Println("Job scheduler shut down")
|
slog.Info("Job scheduler shut down")
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error, runImmediately bool) error {
|
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
|
||||||
jobOptions := []gocron.JobOption{
|
jobOptions := []gocron.JobOption{
|
||||||
gocron.WithContext(ctx),
|
gocron.WithContext(ctx),
|
||||||
gocron.WithEventListeners(
|
gocron.WithEventListeners(
|
||||||
|
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||||
|
slog.Info("Starting job",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.String("id", jobID.String()),
|
||||||
|
)
|
||||||
|
}),
|
||||||
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
|
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||||
log.Printf("Job %q run successfully", name)
|
slog.Info("Job run successfully",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.String("id", jobID.String()),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
|
gocron.AfterJobRunsWithError(func(jobID uuid.UUID, jobName string, err error) {
|
||||||
log.Printf("Job %q failed with error: %v", name, err)
|
slog.Error("Job failed with error",
|
||||||
|
slog.String("name", name),
|
||||||
|
slog.String("id", jobID.String()),
|
||||||
|
slog.Any("error", err),
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -60,11 +73,7 @@ func (s *Scheduler) registerJob(ctx context.Context, name string, interval strin
|
|||||||
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err := s.scheduler.NewJob(
|
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
||||||
gocron.CronJob(interval, false),
|
|
||||||
gocron.NewTask(job),
|
|
||||||
jobOptions...,
|
|
||||||
)
|
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to register job %q: %w", name, err)
|
return fmt.Errorf("failed to register job %q: %w", name, err)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ func (m *CorsMiddleware) Add() gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
c.Writer.Header().Set("Access-Control-Allow-Headers", "Authorization")
|
||||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
|
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
|
||||||
|
|
||||||
// Preflight request
|
// Preflight request
|
||||||
|
|||||||
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)
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
|||||||
|
|
||||||
// Skip rate limiting for localhost and test environment
|
// Skip rate limiting for localhost and test environment
|
||||||
// If the client ip is localhost the request comes from the frontend
|
// If the client ip is localhost the request comes from the frontend
|
||||||
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
||||||
c.Next()
|
c.Next()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
)
|
)
|
||||||
|
|
||||||
type AppConfigVariable struct {
|
type AppConfigVariable struct {
|
||||||
@@ -32,11 +34,15 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
|
|||||||
|
|
||||||
type AppConfig struct {
|
type AppConfig struct {
|
||||||
// General
|
// General
|
||||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,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
|
// Internal
|
||||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||||
@@ -47,7 +53,7 @@ type AppConfig struct {
|
|||||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||||
SmtpPassword AppConfigVariable `key:"smtpPassword"`
|
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
|
||||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||||
@@ -58,7 +64,7 @@ type AppConfig struct {
|
|||||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||||
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
||||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
|
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
|
||||||
LdapBase AppConfigVariable `key:"ldapBase"`
|
LdapBase AppConfigVariable `key:"ldapBase"`
|
||||||
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
||||||
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
||||||
@@ -76,7 +82,7 @@ type AppConfig struct {
|
|||||||
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
func (c *AppConfig) ToAppConfigVariableSlice(showAll bool, redactSensitiveValues bool) []AppConfigVariable {
|
||||||
// Use reflection to iterate through all fields
|
// Use reflection to iterate through all fields
|
||||||
cfgValue := reflect.ValueOf(c).Elem()
|
cfgValue := reflect.ValueOf(c).Elem()
|
||||||
cfgType := cfgValue.Type()
|
cfgType := cfgValue.Type()
|
||||||
@@ -96,11 +102,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
fieldValue := cfgValue.Field(i)
|
value := cfgValue.Field(i).FieldByName("Value").String()
|
||||||
|
|
||||||
|
// Redact sensitive values if the value isn't empty, the UI config is disabled, and redactSensitiveValues is true
|
||||||
|
if value != "" && common.EnvConfig.UiConfigDisabled && redactSensitiveValues && attrs == "sensitive" {
|
||||||
|
value = "XXXXXXXXXX"
|
||||||
|
}
|
||||||
|
|
||||||
appConfigVariable := AppConfigVariable{
|
appConfigVariable := AppConfigVariable{
|
||||||
Key: key,
|
Key: key,
|
||||||
Value: fieldValue.FieldByName("Value").String(),
|
Value: value,
|
||||||
}
|
}
|
||||||
|
|
||||||
res = append(res, appConfigVariable)
|
res = append(res, appConfigVariable)
|
||||||
@@ -169,7 +180,7 @@ type AppConfigKeyNotFoundError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e AppConfigKeyNotFoundError) Error() string {
|
func (e AppConfigKeyNotFoundError) Error() string {
|
||||||
return fmt.Sprintf("cannot find config key '%s'", e.field)
|
return "cannot find config key '" + e.field + "'"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||||
@@ -183,7 +194,7 @@ type AppConfigInternalForbiddenError struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (e AppConfigInternalForbiddenError) Error() string {
|
func (e AppConfigInternalForbiddenError) Error() string {
|
||||||
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
|
return "field '" + e.field + "' is internal and can't be updated"
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e AppConfigInternalForbiddenError) Is(target error) bool {
|
func (e AppConfigInternalForbiddenError) Is(target error) bool {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ type AuditLog struct {
|
|||||||
Base
|
Base
|
||||||
|
|
||||||
Event AuditLogEvent `sortable:"true"`
|
Event AuditLogEvent `sortable:"true"`
|
||||||
IpAddress string `sortable:"true"`
|
IpAddress *string `sortable:"true"`
|
||||||
Country string `sortable:"true"`
|
Country string `sortable:"true"`
|
||||||
City string `sortable:"true"`
|
City string `sortable:"true"`
|
||||||
UserAgent string `sortable:"true"`
|
UserAgent string `sortable:"true"`
|
||||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
|||||||
const (
|
const (
|
||||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||||
|
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
|
||||||
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
|
||||||
|
|||||||
11
backend/internal/model/kv.go
Normal file
11
backend/internal/model/kv.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type KV struct {
|
||||||
|
Key string `gorm:"primaryKey;not null"`
|
||||||
|
Value *string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName overrides the table name used by KV to `kv`
|
||||||
|
func (KV) TableName() string {
|
||||||
|
return "kv"
|
||||||
|
}
|
||||||
@@ -4,13 +4,17 @@ import (
|
|||||||
"database/sql/driver"
|
"database/sql/driver"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type UserAuthorizedOidcClient struct {
|
type UserAuthorizedOidcClient struct {
|
||||||
Scope string
|
Scope string
|
||||||
|
LastUsedAt datatype.DateTime `sortable:"true"`
|
||||||
|
|
||||||
UserID string `gorm:"primary_key;"`
|
UserID string `gorm:"primary_key;"`
|
||||||
User User
|
User User
|
||||||
|
|
||||||
@@ -18,6 +22,14 @@ type UserAuthorizedOidcClient struct {
|
|||||||
Client OidcClient
|
Client OidcClient
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c UserAuthorizedOidcClient) Scopes() []string {
|
||||||
|
if len(c.Scope) == 0 {
|
||||||
|
return []string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Split(c.Scope, " ")
|
||||||
|
}
|
||||||
|
|
||||||
type OidcAuthorizationCode struct {
|
type OidcAuthorizationCode struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
@@ -37,18 +49,22 @@ type OidcAuthorizationCode struct {
|
|||||||
type OidcClient struct {
|
type OidcClient struct {
|
||||||
Base
|
Base
|
||||||
|
|
||||||
Name string `sortable:"true"`
|
Name string `sortable:"true"`
|
||||||
Secret string
|
Secret string
|
||||||
CallbackURLs UrlList
|
CallbackURLs UrlList
|
||||||
LogoutCallbackURLs UrlList
|
LogoutCallbackURLs UrlList
|
||||||
ImageType *string
|
ImageType *string
|
||||||
HasLogo bool `gorm:"-"`
|
HasLogo bool `gorm:"-"`
|
||||||
IsPublic bool
|
IsPublic bool
|
||||||
PkceEnabled bool
|
PkceEnabled bool
|
||||||
|
RequiresReauthentication bool
|
||||||
|
Credentials OidcClientCredentials
|
||||||
|
LaunchURL *string
|
||||||
|
|
||||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||||
CreatedByID string
|
CreatedByID *string
|
||||||
CreatedBy User
|
CreatedBy *User
|
||||||
|
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type OidcRefreshToken struct {
|
type OidcRefreshToken struct {
|
||||||
@@ -65,15 +81,63 @@ type OidcRefreshToken struct {
|
|||||||
Client OidcClient
|
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) {
|
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||||
// Compute HasLogo field
|
// Compute HasLogo field
|
||||||
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
c.HasLogo = c.ImageType != nil && *c.ImageType != ""
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type OidcClientCredentials struct { //nolint:recvcheck
|
||||||
|
FederatedIdentities []OidcClientFederatedIdentity `json:"federatedIdentities,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type OidcClientFederatedIdentity struct {
|
||||||
|
Issuer string `json:"issuer"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
Audience string `json:"audience,omitempty"`
|
||||||
|
JWKS string `json:"jwks,omitempty"` // URL of the JWKS
|
||||||
|
}
|
||||||
|
|
||||||
|
func (occ OidcClientCredentials) FederatedIdentityForIssuer(issuer string) (OidcClientFederatedIdentity, bool) {
|
||||||
|
if issuer == "" {
|
||||||
|
return OidcClientFederatedIdentity{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, fi := range occ.FederatedIdentities {
|
||||||
|
if fi.Issuer == issuer {
|
||||||
|
return fi, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return OidcClientFederatedIdentity{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (occ *OidcClientCredentials) Scan(value any) error {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
return json.Unmarshal(v, occ)
|
||||||
|
case string:
|
||||||
|
return json.Unmarshal([]byte(v), occ)
|
||||||
|
default:
|
||||||
|
return fmt.Errorf("unsupported type: %T", value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (occ OidcClientCredentials) Value() (driver.Value, error) {
|
||||||
|
return json.Marshal(occ)
|
||||||
|
}
|
||||||
|
|
||||||
type UrlList []string //nolint:recvcheck
|
type UrlList []string //nolint:recvcheck
|
||||||
|
|
||||||
func (cu *UrlList) Scan(value interface{}) error {
|
func (cu *UrlList) Scan(value any) error {
|
||||||
switch v := value.(type) {
|
switch v := value.(type) {
|
||||||
case []byte:
|
case []byte:
|
||||||
return json.Unmarshal(v, cu)
|
return json.Unmarshal(v, cu)
|
||||||
|
|||||||
28
backend/internal/model/signup_token.go
Normal file
28
backend/internal/model/signup_token.go
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SignupToken struct {
|
||||||
|
Base
|
||||||
|
|
||||||
|
Token string `json:"token"`
|
||||||
|
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
|
||||||
|
UsageLimit int `json:"usageLimit" sortable:"true"`
|
||||||
|
UsageCount int `json:"usageCount" sortable:"true"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *SignupToken) IsExpired() bool {
|
||||||
|
return time.Time(st.ExpiresAt).Before(time.Now())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *SignupToken) IsUsageLimitReached() bool {
|
||||||
|
return st.UsageCount >= st.UsageLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
func (st *SignupToken) IsValid() bool {
|
||||||
|
return !st.IsExpired() && !st.IsUsageLimitReached()
|
||||||
|
}
|
||||||
@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
|
|||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ReauthenticationToken struct {
|
||||||
|
Base
|
||||||
|
Token string
|
||||||
|
ExpiresAt datatype.DateTime
|
||||||
|
|
||||||
|
UserID string
|
||||||
|
User User
|
||||||
|
}
|
||||||
|
|
||||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
||||||
|
|
||||||
// Scan and Value methods for GORM to handle the custom type
|
// Scan and Value methods for GORM to handle the custom type
|
||||||
|
|||||||
@@ -55,8 +55,8 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
|||||||
apiKey := model.ApiKey{
|
apiKey := model.ApiKey{
|
||||||
Name: input.Name,
|
Name: input.Name,
|
||||||
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
Key: utils.CreateSha256Hash(token), // Hash the token for storage
|
||||||
Description: &input.Description,
|
Description: input.Description,
|
||||||
ExpiresAt: datatype.DateTime(input.ExpiresAt),
|
ExpiresAt: input.ExpiresAt,
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,17 +4,14 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
|
||||||
"mime/multipart"
|
"mime/multipart"
|
||||||
"os"
|
"os"
|
||||||
"reflect"
|
"reflect"
|
||||||
"slices"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/hashicorp/go-uuid"
|
"github.com/hashicorp/go-uuid"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/clause"
|
"gorm.io/gorm/clause"
|
||||||
|
|
||||||
@@ -29,22 +26,22 @@ type AppConfigService struct {
|
|||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewAppConfigService(initCtx context.Context, db *gorm.DB) *AppConfigService {
|
func NewAppConfigService(ctx context.Context, db *gorm.DB) (*AppConfigService, error) {
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := service.LoadDbConfig(initCtx)
|
err := service.LoadDbConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize app config service: %v", err)
|
return nil, fmt.Errorf("failed to initialize app config service: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = service.initInstanceID(initCtx)
|
err = service.initInstanceID(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to initialize instance ID: %v", err)
|
return nil, fmt.Errorf("failed to initialize instance ID: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return service
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDbConfig returns the application configuration.
|
// GetDbConfig returns the application configuration.
|
||||||
@@ -63,13 +60,17 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
|||||||
// Values are the default ones
|
// Values are the default ones
|
||||||
return &model.AppConfig{
|
return &model.AppConfig{
|
||||||
// General
|
// General
|
||||||
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
AppName: model.AppConfigVariable{Value: "Pocket ID"},
|
||||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||||
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
EmailsVerified: model.AppConfigVariable{Value: "false"},
|
||||||
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
DisableAnimations: model.AppConfigVariable{Value: "false"},
|
||||||
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
AllowOwnAccountEdit: model.AppConfigVariable{Value: "true"},
|
||||||
|
AllowUserSignups: model.AppConfigVariable{Value: "disabled"},
|
||||||
|
SignupDefaultUserGroupIDs: model.AppConfigVariable{Value: "[]"},
|
||||||
|
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
|
||||||
|
AccentColor: model.AppConfigVariable{Value: "default"},
|
||||||
// Internal
|
// Internal
|
||||||
BackgroundImageType: model.AppConfigVariable{Value: "jpg"},
|
BackgroundImageType: model.AppConfigVariable{Value: "webp"},
|
||||||
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
LogoLightImageType: model.AppConfigVariable{Value: "svg"},
|
||||||
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
|
LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
|
||||||
InstanceID: model.AppConfigVariable{Value: ""},
|
InstanceID: model.AppConfigVariable{Value: ""},
|
||||||
@@ -232,11 +233,11 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
|
|||||||
s.dbConfig.Store(cfg)
|
s.dbConfig.Store(cfg)
|
||||||
|
|
||||||
// Return the updated config
|
// Return the updated config
|
||||||
res := cfg.ToAppConfigVariableSlice(true)
|
res := cfg.ToAppConfigVariableSlice(true, false)
|
||||||
return res, nil
|
return res, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateAppConfigValues
|
// UpdateAppConfigValues updates the application configuration values in the database.
|
||||||
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndValues ...string) error {
|
||||||
// Count of keysAndValues must be even
|
// Count of keysAndValues must be even
|
||||||
if len(keysAndValues)%2 != 0 {
|
if len(keysAndValues)%2 != 0 {
|
||||||
@@ -317,11 +318,11 @@ func (s *AppConfigService) UpdateAppConfigValues(ctx context.Context, keysAndVal
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
func (s *AppConfigService) ListAppConfig(showAll bool) []model.AppConfigVariable {
|
||||||
return s.GetDbConfig().ToAppConfigVariableSlice(showAll)
|
return s.GetDbConfig().ToAppConfigVariableSlice(showAll, true)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
|
func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multipart.FileHeader, imageName string, oldImageType string) (err error) {
|
||||||
fileType := utils.GetFileExtension(uploadedFile.Filename)
|
fileType := strings.ToLower(utils.GetFileExtension(uploadedFile.Filename))
|
||||||
mimeType := utils.GetImageMimeType(fileType)
|
mimeType := utils.GetImageMimeType(fileType)
|
||||||
if mimeType == "" {
|
if mimeType == "" {
|
||||||
return &common.FileTypeNotSupportedError{}
|
return &common.FileTypeNotSupportedError{}
|
||||||
@@ -355,24 +356,52 @@ func (s *AppConfigService) UpdateImage(ctx context.Context, uploadedFile *multip
|
|||||||
|
|
||||||
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
// LoadDbConfig loads the configuration values from the database into the DbConfig struct.
|
||||||
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
func (s *AppConfigService) LoadDbConfig(ctx context.Context) (err error) {
|
||||||
var dest *model.AppConfig
|
dest, err := s.loadDbConfigInternal(ctx, s.db)
|
||||||
|
|
||||||
// If the UI config is disabled, only load from the env
|
|
||||||
if common.EnvConfig.UiConfigDisabled {
|
|
||||||
dest, err = s.loadDbConfigFromEnv(ctx, s.db)
|
|
||||||
} else {
|
|
||||||
dest, err = s.loadDbConfigInternal(ctx, s.db)
|
|
||||||
}
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the value in the object
|
|
||||||
s.dbConfig.Store(dest)
|
s.dbConfig.Store(dest)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||||
|
// If the UI config is disabled, only load from the env
|
||||||
|
if common.EnvConfig.UiConfigDisabled {
|
||||||
|
dest, err := s.loadDbConfigFromEnv(ctx, tx)
|
||||||
|
return dest, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// First, start from the default configuration
|
||||||
|
dest := s.getDefaultDbConfig()
|
||||||
|
|
||||||
|
// Load all configuration values from the database
|
||||||
|
// This loads all values in a single shot
|
||||||
|
var loaded []model.AppConfigVariable
|
||||||
|
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
defer queryCancel()
|
||||||
|
err := tx.
|
||||||
|
WithContext(queryCtx).
|
||||||
|
Find(&loaded).Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through all values loaded from the database
|
||||||
|
for _, v := range loaded {
|
||||||
|
// Find the field in the struct whose "key" tag matches, then update that
|
||||||
|
err = dest.UpdateField(v.Key, v.Value, false)
|
||||||
|
|
||||||
|
// We ignore the case of fields that don't exist, as there may be leftover data in the database
|
||||||
|
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
|
||||||
|
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return dest, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
||||||
// First, start from the default configuration
|
// First, start from the default configuration
|
||||||
dest := s.getDefaultDbConfig()
|
dest := s.getDefaultDbConfig()
|
||||||
@@ -384,12 +413,10 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
|
|||||||
field := rt.Field(i)
|
field := rt.Field(i)
|
||||||
|
|
||||||
// Get the key and internal tag values
|
// Get the key and internal tag values
|
||||||
tagValue := strings.Split(field.Tag.Get("key"), ",")
|
key, attrs, _ := strings.Cut(field.Tag.Get("key"), ",")
|
||||||
key := tagValue[0]
|
|
||||||
isInternal := slices.Contains(tagValue, "internal")
|
|
||||||
|
|
||||||
// Internal fields are loaded from the database as they can't be set from the environment
|
// Internal fields are loaded from the database as they can't be set from the environment
|
||||||
if isInternal {
|
if attrs == "internal" {
|
||||||
var value string
|
var value string
|
||||||
err := tx.WithContext(ctx).
|
err := tx.WithContext(ctx).
|
||||||
Model(&model.AppConfigVariable{}).
|
Model(&model.AppConfigVariable{}).
|
||||||
@@ -408,41 +435,20 @@ func (s *AppConfigService) loadDbConfigFromEnv(ctx context.Context, tx *gorm.DB)
|
|||||||
value, ok := os.LookupEnv(envVarName)
|
value, ok := os.LookupEnv(envVarName)
|
||||||
if ok {
|
if ok {
|
||||||
rv.Field(i).FieldByName("Value").SetString(value)
|
rv.Field(i).FieldByName("Value").SetString(value)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return dest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *AppConfigService) loadDbConfigInternal(ctx context.Context, tx *gorm.DB) (*model.AppConfig, error) {
|
|
||||||
// First, start from the default configuration
|
|
||||||
dest := s.getDefaultDbConfig()
|
|
||||||
|
|
||||||
// Load all configuration values from the database
|
|
||||||
// This loads all values in a single shot
|
|
||||||
var loaded []model.AppConfigVariable
|
|
||||||
queryCtx, queryCancel := context.WithTimeout(ctx, 10*time.Second)
|
|
||||||
defer queryCancel()
|
|
||||||
err := tx.
|
|
||||||
WithContext(queryCtx).
|
|
||||||
Find(&loaded).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to load configuration from the database: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through all values loaded from the database
|
|
||||||
for _, v := range loaded {
|
|
||||||
// If the value is empty, it means we are using the default value
|
|
||||||
if v.Value == "" {
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the field in the struct whose "key" tag matches, then update that
|
// If it's sensitive, we also allow reading from file
|
||||||
err = dest.UpdateField(v.Key, v.Value, false)
|
if attrs == "sensitive" {
|
||||||
|
fileName := os.Getenv(envVarName + "_FILE")
|
||||||
// We ignore the case of fields that don't exist, as there may be leftover data in the database
|
if fileName != "" {
|
||||||
if err != nil && !errors.Is(err, model.AppConfigKeyNotFoundError{}) {
|
b, err := os.ReadFile(fileName)
|
||||||
return nil, fmt.Errorf("failed to process config for key '%s': %w", v.Key, err)
|
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,17 +3,13 @@ package service
|
|||||||
import (
|
import (
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/glebarez/sqlite"
|
"github.com/stretchr/testify/require"
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/logger"
|
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
// NewTestAppConfigService is a function used by tests to create AppConfigService objects with pre-defined configuration values
|
||||||
@@ -28,7 +24,7 @@ func NewTestAppConfigService(config *model.AppConfig) *AppConfigService {
|
|||||||
|
|
||||||
func TestLoadDbConfig(t *testing.T) {
|
func TestLoadDbConfig(t *testing.T) {
|
||||||
t.Run("empty config table", func(t *testing.T) {
|
t.Run("empty config table", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
@@ -42,14 +38,13 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("loads value from config table", func(t *testing.T) {
|
t.Run("loads value from config table", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Populate the config table with some initial values
|
// Populate the config table with some initial values
|
||||||
err := db.
|
err := db.
|
||||||
Create([]model.AppConfigVariable{
|
Create([]model.AppConfigVariable{
|
||||||
// Should be set to the default value because it's an empty string
|
|
||||||
{Key: "appName", Value: ""},
|
|
||||||
// Overrides default value
|
// Overrides default value
|
||||||
|
{Key: "appName", Value: "Test App"},
|
||||||
{Key: "sessionDuration", Value: "5"},
|
{Key: "sessionDuration", Value: "5"},
|
||||||
// Does not have a default value
|
// Does not have a default value
|
||||||
{Key: "smtpHost", Value: "example"},
|
{Key: "smtpHost", Value: "example"},
|
||||||
@@ -66,13 +61,14 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
|
|
||||||
// Values should match expected ones
|
// Values should match expected ones
|
||||||
expect := service.getDefaultDbConfig()
|
expect := service.getDefaultDbConfig()
|
||||||
|
expect.AppName.Value = "Test App"
|
||||||
expect.SessionDuration.Value = "5"
|
expect.SessionDuration.Value = "5"
|
||||||
expect.SmtpHost.Value = "example"
|
expect.SmtpHost.Value = "example"
|
||||||
require.Equal(t, service.GetDbConfig(), expect)
|
require.Equal(t, service.GetDbConfig(), expect)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("ignores unknown config keys", func(t *testing.T) {
|
t.Run("ignores unknown config keys", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Add an entry with a key that doesn't exist in the config struct
|
// Add an entry with a key that doesn't exist in the config struct
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
@@ -93,7 +89,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("loading config multiple times", func(t *testing.T) {
|
t.Run("loading config multiple times", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Initial state
|
// Initial state
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
@@ -135,7 +131,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
common.EnvConfig.UiConfigDisabled = true
|
common.EnvConfig.UiConfigDisabled = true
|
||||||
|
|
||||||
// Create database with config that should be ignored
|
// Create database with config that should be ignored
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
{Key: "appName", Value: "DB App"},
|
{Key: "appName", Value: "DB App"},
|
||||||
{Key: "sessionDuration", Value: "120"},
|
{Key: "sessionDuration", Value: "120"},
|
||||||
@@ -171,7 +167,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
common.EnvConfig.UiConfigDisabled = false
|
common.EnvConfig.UiConfigDisabled = false
|
||||||
|
|
||||||
// Create database with config values that should take precedence
|
// Create database with config values that should take precedence
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
err := db.Create([]model.AppConfigVariable{
|
err := db.Create([]model.AppConfigVariable{
|
||||||
{Key: "appName", Value: "DB App"},
|
{Key: "appName", Value: "DB App"},
|
||||||
{Key: "sessionDuration", Value: "120"},
|
{Key: "sessionDuration", Value: "120"},
|
||||||
@@ -195,7 +191,7 @@ func TestLoadDbConfig(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateAppConfigValues(t *testing.T) {
|
func TestUpdateAppConfigValues(t *testing.T) {
|
||||||
t.Run("update single value", func(t *testing.T) {
|
t.Run("update single value", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -220,7 +216,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("update multiple values", func(t *testing.T) {
|
t.Run("update multiple values", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -264,7 +260,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty value resets to default", func(t *testing.T) {
|
t.Run("empty value resets to default", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -285,7 +281,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error with odd number of arguments", func(t *testing.T) {
|
t.Run("error with odd number of arguments", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -301,7 +297,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("error with invalid key", func(t *testing.T) {
|
t.Run("error with invalid key", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -319,7 +315,7 @@ func TestUpdateAppConfigValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestUpdateAppConfig(t *testing.T) {
|
func TestUpdateAppConfig(t *testing.T) {
|
||||||
t.Run("updates configuration values from DTO", func(t *testing.T) {
|
t.Run("updates configuration values from DTO", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config
|
// Create a service with default config
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -392,7 +388,7 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("empty values reset to defaults", func(t *testing.T) {
|
t.Run("empty values reset to defaults", func(t *testing.T) {
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
|
|
||||||
// Create a service with default config and modify some values
|
// Create a service with default config and modify some values
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
@@ -457,7 +453,7 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
// Disable UI config
|
// Disable UI config
|
||||||
common.EnvConfig.UiConfigDisabled = true
|
common.EnvConfig.UiConfigDisabled = true
|
||||||
|
|
||||||
db := newAppConfigTestDatabaseForTest(t)
|
db := testutils.NewDatabaseForTest(t)
|
||||||
service := &AppConfigService{
|
service := &AppConfigService{
|
||||||
db: db,
|
db: db,
|
||||||
}
|
}
|
||||||
@@ -475,49 +471,3 @@ func TestUpdateAppConfig(t *testing.T) {
|
|||||||
require.ErrorAs(t, err, &uiConfigDisabledErr)
|
require.ErrorAs(t, err, &uiConfigDisabledErr)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Implements gorm's logger.Writer interface
|
|
||||||
type testLoggerAdapter struct {
|
|
||||||
t *testing.T
|
|
||||||
}
|
|
||||||
|
|
||||||
func (l testLoggerAdapter) Printf(format string, args ...any) {
|
|
||||||
l.t.Logf(format, args...)
|
|
||||||
}
|
|
||||||
|
|
||||||
func newAppConfigTestDatabaseForTest(t *testing.T) *gorm.DB {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
// Get a name for this in-memory database that is specific to the test
|
|
||||||
dbName := utils.CreateSha256Hash(t.Name())
|
|
||||||
|
|
||||||
// Connect to a new in-memory SQL database
|
|
||||||
db, err := gorm.Open(
|
|
||||||
sqlite.Open("file:"+dbName+"?mode=memory&cache=shared"),
|
|
||||||
&gorm.Config{
|
|
||||||
TranslateError: true,
|
|
||||||
Logger: logger.New(
|
|
||||||
testLoggerAdapter{t: t},
|
|
||||||
logger.Config{
|
|
||||||
SlowThreshold: 200 * time.Millisecond,
|
|
||||||
LogLevel: logger.Info,
|
|
||||||
IgnoreRecordNotFoundError: false,
|
|
||||||
ParameterizedQueries: false,
|
|
||||||
Colorful: false,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
})
|
|
||||||
require.NoError(t, err, "Failed to connect to test database")
|
|
||||||
|
|
||||||
// Create the app_config_variables table
|
|
||||||
err = db.Exec(`
|
|
||||||
CREATE TABLE app_config_variables
|
|
||||||
(
|
|
||||||
key VARCHAR(100) NOT NULL PRIMARY KEY,
|
|
||||||
value TEXT NOT NULL
|
|
||||||
)
|
|
||||||
`).Error
|
|
||||||
require.NoError(t, err, "Failed to create test config table")
|
|
||||||
|
|
||||||
return db
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,13 +3,14 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
|
|
||||||
userAgentParser "github.com/mileusna/useragent"
|
userAgentParser "github.com/mileusna/useragent"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
"github.com/pocket-id/pocket-id/backend/internal/utils/email"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,19 +22,24 @@ type AuditLogService struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
func NewAuditLogService(db *gorm.DB, appConfigService *AppConfigService, emailService *EmailService, geoliteService *GeoLiteService) *AuditLogService {
|
||||||
return &AuditLogService{db: db, appConfigService: appConfigService, emailService: emailService, geoliteService: geoliteService}
|
return &AuditLogService{
|
||||||
|
db: db,
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
emailService: emailService,
|
||||||
|
geoliteService: geoliteService,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create creates a new audit log entry in the database
|
// Create creates a new audit log entry in the database
|
||||||
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) model.AuditLog {
|
func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent, ipAddress, userAgent, userID string, data model.AuditLogData, tx *gorm.DB) (model.AuditLog, bool) {
|
||||||
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to get IP location: %v", err)
|
// Log the error but don't interrupt the operation
|
||||||
|
slog.Warn("Failed to get IP location", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
auditLog := model.AuditLog{
|
auditLog := model.AuditLog{
|
||||||
Event: event,
|
Event: event,
|
||||||
IpAddress: ipAddress,
|
|
||||||
Country: country,
|
Country: country,
|
||||||
City: city,
|
City: city,
|
||||||
UserAgent: userAgent,
|
UserAgent: userAgent,
|
||||||
@@ -41,33 +47,47 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
|
|||||||
Data: data,
|
Data: data,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ipAddress != "" {
|
||||||
|
// Only set ipAddress if not empty, because on Postgres we use INET columns that don't allow non-null empty values
|
||||||
|
auditLog.IpAddress = &ipAddress
|
||||||
|
}
|
||||||
|
|
||||||
// Save the audit log in the database
|
// Save the audit log in the database
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Create(&auditLog).
|
Create(&auditLog).
|
||||||
Error
|
Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to create audit log: %v", err)
|
slog.Error("Failed to create audit log", "error", err)
|
||||||
return model.AuditLog{}
|
return model.AuditLog{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
return auditLog
|
return auditLog, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
// CreateNewSignInWithEmail creates a new audit log entry in the database and sends an email if the device hasn't been used before
|
||||||
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
|
func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddress, userAgent, userID string, tx *gorm.DB) model.AuditLog {
|
||||||
createdAuditLog := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
|
createdAuditLog, ok := s.Create(ctx, model.AuditLogEventSignIn, ipAddress, userAgent, userID, model.AuditLogData{}, tx)
|
||||||
|
if !ok {
|
||||||
|
// At this point the transaction has been canceled already, and error has been logged
|
||||||
|
return createdAuditLog
|
||||||
|
}
|
||||||
|
|
||||||
// Count the number of times the user has logged in from the same device
|
// Count the number of times the user has logged in from the same device
|
||||||
var count int64
|
var count int64
|
||||||
err := tx.
|
stmt := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Model(&model.AuditLog{}).
|
Model(&model.AuditLog{}).
|
||||||
Where("user_id = ? AND ip_address = ? AND user_agent = ?", userID, ipAddress, userAgent).
|
Where("user_id = ? AND user_agent = ?", userID, userAgent)
|
||||||
Count(&count).
|
if ipAddress == "" {
|
||||||
Error
|
// An empty IP address is stored as NULL in the database
|
||||||
|
stmt = stmt.Where("ip_address IS NULL")
|
||||||
|
} else {
|
||||||
|
stmt = stmt.Where("ip_address = ?", ipAddress)
|
||||||
|
}
|
||||||
|
err := stmt.Count(&count).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to count audit logs: %v\n", err)
|
slog.ErrorContext(ctx, "Failed to count audit logs", slog.Any("error", err))
|
||||||
return createdAuditLog
|
return createdAuditLog
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +96,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
|||||||
// We use a background context here as this is running in a goroutine
|
// We use a background context here as this is running in a goroutine
|
||||||
//nolint:contextcheck
|
//nolint:contextcheck
|
||||||
go func() {
|
go func() {
|
||||||
innerCtx := context.Background()
|
span := trace.SpanFromContext(ctx)
|
||||||
|
innerCtx := trace.ContextWithSpan(context.Background(), span)
|
||||||
|
|
||||||
// Note we don't use the transaction here because this is running in background
|
// Note we don't use the transaction here because this is running in background
|
||||||
var user model.User
|
var user model.User
|
||||||
@@ -86,7 +107,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
|||||||
First(&user).
|
First(&user).
|
||||||
Error
|
Error
|
||||||
if innerErr != nil {
|
if innerErr != nil {
|
||||||
log.Printf("Failed to load user: %v", innerErr)
|
slog.ErrorContext(innerCtx, "Failed to load user from database to send notification email", slog.Any("error", innerErr))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
innerErr = SendEmail(innerCtx, s.emailService, email.Address{
|
||||||
@@ -100,7 +122,8 @@ func (s *AuditLogService) CreateNewSignInWithEmail(ctx context.Context, ipAddres
|
|||||||
DateTime: createdAuditLog.CreatedAt.UTC(),
|
DateTime: createdAuditLog.CreatedAt.UTC(),
|
||||||
})
|
})
|
||||||
if innerErr != nil {
|
if innerErr != nil {
|
||||||
log.Printf("Failed to send email to '%s': %v", user.Email, innerErr)
|
slog.ErrorContext(innerCtx, "Failed to send notification email", slog.Any("error", innerErr), slog.String("address", user.Email))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
@@ -150,6 +173,14 @@ func (s *AuditLogService) ListAllAuditLogs(ctx context.Context, sortedPagination
|
|||||||
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
return nil, utils.PaginationResponse{}, fmt.Errorf("unsupported database dialect: %s", dialect)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if filters.Location != "" {
|
||||||
|
switch filters.Location {
|
||||||
|
case "external":
|
||||||
|
query = query.Where("country != 'Internal Network'")
|
||||||
|
case "internal":
|
||||||
|
query = query.Where("country = 'Internal Network'")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
pagination, err := utils.PaginateAndSort(sortedPaginationRequest, query, &logs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -55,16 +55,46 @@ const (
|
|||||||
|
|
||||||
// UpdateCustomClaimsForUser updates the custom claims for a user
|
// UpdateCustomClaimsForUser updates the custom claims for a user
|
||||||
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
func (s *CustomClaimService) UpdateCustomClaimsForUser(ctx context.Context, userID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
return s.updateCustomClaims(ctx, UserID, userID, claims)
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserID, userID, claims, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedClaims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
// UpdateCustomClaimsForUserGroup updates the custom claims for a user group
|
||||||
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
func (s *CustomClaimService) UpdateCustomClaimsForUserGroup(ctx context.Context, userGroupID string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
||||||
return s.updateCustomClaims(ctx, UserGroupID, userGroupID, claims)
|
tx := s.db.Begin()
|
||||||
|
defer func() {
|
||||||
|
tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
updatedClaims, err := s.updateCustomClaimsInternal(ctx, UserGroupID, userGroupID, claims, tx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit().Error
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return updatedClaims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateCustomClaims updates the custom claims for a user or user group
|
// updateCustomClaimsInternal updates the custom claims for a user or user group within a transaction
|
||||||
func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto) ([]model.CustomClaim, error) {
|
func (s *CustomClaimService) updateCustomClaimsInternal(ctx context.Context, idType idType, value string, claims []dto.CustomClaimCreateDto, tx *gorm.DB) ([]model.CustomClaim, error) {
|
||||||
// Check for duplicate keys in the claims slice
|
// Check for duplicate keys in the claims slice
|
||||||
seenKeys := make(map[string]struct{})
|
seenKeys := make(map[string]struct{})
|
||||||
for _, claim := range claims {
|
for _, claim := range claims {
|
||||||
@@ -74,11 +104,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
|
|||||||
seenKeys[claim.Key] = struct{}{}
|
seenKeys[claim.Key] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
tx := s.db.Begin()
|
|
||||||
defer func() {
|
|
||||||
tx.Rollback()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var existingClaims []model.CustomClaim
|
var existingClaims []model.CustomClaim
|
||||||
err := tx.
|
err := tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
@@ -150,11 +175,6 @@ func (s *CustomClaimService) updateCustomClaims(ctx context.Context, idType idTy
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Commit().Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return updatedClaims, nil
|
return updatedClaims, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,23 +5,28 @@ package service
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/ecdsa"
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
"crypto/x509"
|
"crypto/x509"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/fxamacker/cbor/v2"
|
"github.com/fxamacker/cbor/v2"
|
||||||
"github.com/go-webauthn/webauthn/protocol"
|
"github.com/go-webauthn/webauthn/protocol"
|
||||||
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||||
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||||
|
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||||
"github.com/pocket-id/pocket-id/backend/resources"
|
"github.com/pocket-id/pocket-id/backend/resources"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -30,14 +35,43 @@ type TestService struct {
|
|||||||
jwtService *JwtService
|
jwtService *JwtService
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
ldapService *LdapService
|
ldapService *LdapService
|
||||||
|
externalIdPKey jwk.Key
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) *TestService {
|
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService) (*TestService, error) {
|
||||||
return &TestService{db: db, appConfigService: appConfigService, jwtService: jwtService, ldapService: ldapService}
|
s := &TestService{
|
||||||
|
db: db,
|
||||||
|
appConfigService: appConfigService,
|
||||||
|
jwtService: jwtService,
|
||||||
|
ldapService: ldapService,
|
||||||
|
}
|
||||||
|
err := s.initExternalIdP()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to initialize external IdP: %w", err)
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initializes the "external IdP"
|
||||||
|
// This creates a new "issuing authority" containing a public JWKS
|
||||||
|
// It also stores the private key internally that will be used to issue JWTs
|
||||||
|
func (s *TestService) initExternalIdP() error {
|
||||||
|
// Generate a new ECDSA key
|
||||||
|
rawKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.externalIdPKey, err = jwkutils.ImportRawKey(rawKey, jwa.ES256().String(), "")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to import private key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
//nolint:gocognit
|
//nolint:gocognit
|
||||||
func (s *TestService) SeedDatabase() error {
|
func (s *TestService) SeedDatabase(baseURL string) error {
|
||||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||||
users := []model.User{
|
users := []model.User{
|
||||||
{
|
{
|
||||||
@@ -120,11 +154,12 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||||
},
|
},
|
||||||
Name: "Nextcloud",
|
Name: "Nextcloud",
|
||||||
|
LaunchURL: utils.Ptr("https://nextcloud.local"),
|
||||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||||
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||||
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||||
ImageType: utils.StringPointer("png"),
|
ImageType: utils.StringPointer("png"),
|
||||||
CreatedByID: users[0].ID,
|
CreatedByID: utils.Ptr(users[0].ID),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
Base: model.Base{
|
Base: model.Base{
|
||||||
@@ -133,11 +168,41 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
Name: "Immich",
|
Name: "Immich",
|
||||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||||
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||||
CreatedByID: users[1].ID,
|
CreatedByID: utils.Ptr(users[1].ID),
|
||||||
AllowedUserGroups: []model.UserGroup{
|
AllowedUserGroups: []model.UserGroup{
|
||||||
userGroups[1],
|
userGroups[1],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "7c21a609-96b5-4011-9900-272b8d31a9d1",
|
||||||
|
},
|
||||||
|
Name: "Tailscale",
|
||||||
|
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
|
||||||
|
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
|
||||||
|
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
|
||||||
|
CreatedByID: utils.Ptr(users[0].ID),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||||
|
},
|
||||||
|
Name: "Federated",
|
||||||
|
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||||
|
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
|
||||||
|
CreatedByID: utils.Ptr(users[1].ID),
|
||||||
|
AllowedUserGroups: []model.UserGroup{},
|
||||||
|
Credentials: model.OidcClientCredentials{
|
||||||
|
FederatedIdentities: []model.OidcClientFederatedIdentity{
|
||||||
|
{
|
||||||
|
Issuer: "https://external-idp.local",
|
||||||
|
Audience: "api://PocketID",
|
||||||
|
Subject: "c48232ff-ff65-45ed-ae96-7afa8a9b443b",
|
||||||
|
JWKS: baseURL + "/api/externalidp/jwks.json",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, client := range oidcClients {
|
for _, client := range oidcClients {
|
||||||
if err := tx.Create(&client).Error; err != nil {
|
if err := tx.Create(&client).Error; err != nil {
|
||||||
@@ -145,16 +210,28 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authCode := model.OidcAuthorizationCode{
|
authCodes := []model.OidcAuthorizationCode{
|
||||||
Code: "auth-code",
|
{
|
||||||
Scope: "openid profile",
|
Code: "auth-code",
|
||||||
Nonce: "nonce",
|
Scope: "openid profile",
|
||||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
Nonce: "nonce",
|
||||||
UserID: users[0].ID,
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
ClientID: oidcClients[0].ID,
|
UserID: users[0].ID,
|
||||||
|
ClientID: oidcClients[0].ID,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Code: "federated",
|
||||||
|
Scope: "openid profile",
|
||||||
|
Nonce: "nonce",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||||
|
UserID: users[1].ID,
|
||||||
|
ClientID: oidcClients[2].ID,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if err := tx.Create(&authCode).Error; err != nil {
|
for _, authCode := range authCodes {
|
||||||
return err
|
if err := tx.Create(&authCode).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshToken := model.OidcRefreshToken{
|
refreshToken := model.OidcRefreshToken{
|
||||||
@@ -177,13 +254,30 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
userAuthorizedClient := model.UserAuthorizedOidcClient{
|
userAuthorizedClients := []model.UserAuthorizedOidcClient{
|
||||||
Scope: "openid profile email",
|
{
|
||||||
UserID: users[0].ID,
|
Scope: "openid profile email",
|
||||||
ClientID: oidcClients[0].ID,
|
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[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)),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
for _, userAuthorizedClient := range userAuthorizedClients {
|
||||||
return err
|
if err := tx.Create(&userAuthorizedClient).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// To generate a new key pair, run the following command:
|
// To generate a new key pair, run the following command:
|
||||||
@@ -237,6 +331,50 @@ func (s *TestService) SeedDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
signupTokens := []model.SignupToken{
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||||
|
},
|
||||||
|
Token: "VALID1234567890A",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||||
|
UsageLimit: 1,
|
||||||
|
UsageCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
|
||||||
|
},
|
||||||
|
Token: "PARTIAL567890ABC",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
|
||||||
|
UsageLimit: 5,
|
||||||
|
UsageCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
|
||||||
|
},
|
||||||
|
Token: "EXPIRED34567890B",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
|
||||||
|
UsageLimit: 3,
|
||||||
|
UsageCount: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Base: model.Base{
|
||||||
|
ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
|
||||||
|
},
|
||||||
|
Token: "FULLYUSED567890C",
|
||||||
|
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||||
|
UsageLimit: 1,
|
||||||
|
UsageCount: 1, // Usage limit reached
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, token := range signupTokens {
|
||||||
|
if err := tx.Create(&token).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -283,9 +421,9 @@ func (s *TestService) ResetDatabase() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *TestService) ResetApplicationImages() error {
|
func (s *TestService) ResetApplicationImages(ctx context.Context) error {
|
||||||
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
if err := os.RemoveAll(common.EnvConfig.UploadPath); err != nil {
|
||||||
log.Printf("Error removing directory: %v", err)
|
slog.ErrorContext(ctx, "Error removing directory", slog.Any("error", err))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -405,3 +543,45 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *TestService) SignRefreshToken(userID, clientID, refreshToken string) (string, error) {
|
||||||
|
return s.jwtService.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetExternalIdPJWKS returns the JWKS for the "external IdP".
|
||||||
|
func (s *TestService) GetExternalIdPJWKS() (jwk.Set, error) {
|
||||||
|
pubKey, err := s.externalIdPKey.PublicKey()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
set := jwk.NewSet()
|
||||||
|
err = set.AddKey(pubKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to add public key to set: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return set, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *TestService) SignExternalIdPToken(iss, sub, aud string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
token, err := jwt.NewBuilder().
|
||||||
|
Subject(sub).
|
||||||
|
Expiration(now.Add(time.Hour)).
|
||||||
|
IssuedAt(now).
|
||||||
|
Issuer(iss).
|
||||||
|
Audience([]string{aud}).
|
||||||
|
Build()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to build token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
alg, _ := s.externalIdPKey.Algorithm()
|
||||||
|
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.externalIdPKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(signed), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
|
|||||||
|
|
||||||
// prepare text part
|
// prepare text part
|
||||||
var textHeader = textproto.MIMEHeader{}
|
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")
|
textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
||||||
textPart, err := mpart.CreatePart(textHeader)
|
textPart, err := mpart.CreatePart(textHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -274,18 +274,17 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("execute text template: %w", err)
|
return "", "", fmt.Errorf("execute text template: %w", err)
|
||||||
}
|
}
|
||||||
|
textQp.Close()
|
||||||
|
|
||||||
// prepare html part
|
|
||||||
var htmlHeader = textproto.MIMEHeader{}
|
var htmlHeader = textproto.MIMEHeader{}
|
||||||
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8")
|
htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
|
||||||
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable")
|
htmlHeader.Add("Content-Transfer-Encoding", "8bit")
|
||||||
htmlPart, err := mpart.CreatePart(htmlHeader)
|
htmlPart, err := mpart.CreatePart(htmlHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("create html part: %w", err)
|
return "", "", fmt.Errorf("create html part: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
htmlQp := quotedprintable.NewWriter(htmlPart)
|
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlPart, "root", data)
|
||||||
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", fmt.Errorf("execute html template: %w", err)
|
return "", "", fmt.Errorf("execute html template: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -22,9 +23,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GeoLiteService struct {
|
type GeoLiteService struct {
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
disableUpdater bool
|
disableUpdater bool
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
localIPv6Ranges []*net.IPNet
|
||||||
}
|
}
|
||||||
|
|
||||||
var localhostIPNets = []*net.IPNet{
|
var localhostIPNets = []*net.IPNet{
|
||||||
@@ -50,21 +52,89 @@ func NewGeoLiteService(httpClient *http.Client) *GeoLiteService {
|
|||||||
|
|
||||||
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
|
if common.EnvConfig.MaxMindLicenseKey == "" && common.EnvConfig.GeoLiteDBUrl == common.MaxMindGeoLiteCityUrl {
|
||||||
// Warn the user, and disable the periodic updater
|
// Warn the user, and disable the periodic updater
|
||||||
log.Println("MAXMIND_LICENSE_KEY environment variable is empty. The GeoLite2 City database won't be updated.")
|
slog.Warn("MAXMIND_LICENSE_KEY environment variable is empty: the GeoLite2 City database won't be updated")
|
||||||
service.disableUpdater = true
|
service.disableUpdater = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize IPv6 local ranges
|
||||||
|
err := service.initializeIPv6LocalRanges()
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("Failed to initialize IPv6 local ranges", slog.Any("error", err))
|
||||||
|
}
|
||||||
|
|
||||||
return service
|
return service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// initializeIPv6LocalRanges parses the LOCAL_IPV6_RANGES environment variable
|
||||||
|
func (s *GeoLiteService) initializeIPv6LocalRanges() error {
|
||||||
|
rangesEnv := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
if rangesEnv == "" {
|
||||||
|
return nil // No local IPv6 ranges configured
|
||||||
|
}
|
||||||
|
|
||||||
|
ranges := strings.Split(rangesEnv, ",")
|
||||||
|
localRanges := make([]*net.IPNet, 0, len(ranges))
|
||||||
|
|
||||||
|
for _, rangeStr := range ranges {
|
||||||
|
rangeStr = strings.TrimSpace(rangeStr)
|
||||||
|
if rangeStr == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("invalid IPv6 range '%s': %w", rangeStr, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure it's an IPv6 range
|
||||||
|
if ipNet.IP.To4() != nil {
|
||||||
|
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
localRanges = append(localRanges, ipNet)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.localIPv6Ranges = localRanges
|
||||||
|
|
||||||
|
if len(localRanges) > 0 {
|
||||||
|
slog.Info("Initialized IPv6 local ranges", slog.Int("count", len(localRanges)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// isLocalIPv6 checks if the given IPv6 address is within any of the configured local ranges
|
||||||
|
func (s *GeoLiteService) isLocalIPv6(ip net.IP) bool {
|
||||||
|
if ip.To4() != nil {
|
||||||
|
return false // Not an IPv6 address
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, localRange := range s.localIPv6Ranges {
|
||||||
|
if localRange.Contains(ip) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GeoLiteService) DisableUpdater() bool {
|
func (s *GeoLiteService) DisableUpdater() bool {
|
||||||
return s.disableUpdater
|
return s.disableUpdater
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocationByIP returns the country and city of the given IP address.
|
// GetLocationByIP returns the country and city of the given IP address.
|
||||||
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string, err error) {
|
||||||
|
if ipAddress == "" {
|
||||||
|
return "", "", nil
|
||||||
|
}
|
||||||
|
|
||||||
// Check the IP address against known private IP ranges
|
// Check the IP address against known private IP ranges
|
||||||
if ip := net.ParseIP(ipAddress); ip != nil {
|
if ip := net.ParseIP(ipAddress); ip != nil {
|
||||||
|
// Check IPv6 local ranges first
|
||||||
|
if s.isLocalIPv6(ip) {
|
||||||
|
return "Internal Network", "LAN", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check existing IPv4 ranges
|
||||||
for _, ipNet := range tailscaleIPNets {
|
for _, ipNet := range tailscaleIPNets {
|
||||||
if ipNet.Contains(ip) {
|
if ipNet.Contains(ip) {
|
||||||
return "Internal Network", "Tailscale", nil
|
return "Internal Network", "Tailscale", nil
|
||||||
@@ -72,7 +142,7 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
|||||||
}
|
}
|
||||||
for _, ipNet := range privateLanIPNets {
|
for _, ipNet := range privateLanIPNets {
|
||||||
if ipNet.Contains(ip) {
|
if ipNet.Contains(ip) {
|
||||||
return "Internal Network", "LAN/Docker/k8s", nil
|
return "Internal Network", "LAN", nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, ipNet := range localhostIPNets {
|
for _, ipNet := range localhostIPNets {
|
||||||
@@ -82,6 +152,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addr, err := netip.ParseAddr(ipAddress)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// Race condition between reading and writing the database.
|
// Race condition between reading and writing the database.
|
||||||
s.mutex.RLock()
|
s.mutex.RLock()
|
||||||
defer s.mutex.RUnlock()
|
defer s.mutex.RUnlock()
|
||||||
@@ -92,11 +167,6 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
addr, err := netip.ParseAddr(ipAddress)
|
|
||||||
if err != nil {
|
|
||||||
return "", "", fmt.Errorf("failed to parse IP address: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var record struct {
|
var record struct {
|
||||||
City struct {
|
City struct {
|
||||||
Names map[string]string `maxminddb:"names"`
|
Names map[string]string `maxminddb:"names"`
|
||||||
@@ -117,11 +187,11 @@ func (s *GeoLiteService) GetLocationByIP(ipAddress string) (country, city string
|
|||||||
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
// UpdateDatabase checks the age of the database and updates it if it's older than 14 days.
|
||||||
func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
||||||
if s.isDatabaseUpToDate() {
|
if s.isDatabaseUpToDate() {
|
||||||
log.Println("GeoLite2 City database is up-to-date")
|
slog.Info("GeoLite2 City database is up-to-date")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("Updating GeoLite2 City database")
|
slog.Info("Updating GeoLite2 City database")
|
||||||
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
|
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
|
||||||
@@ -148,7 +218,7 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
|||||||
return fmt.Errorf("failed to extract database: %w", err)
|
return fmt.Errorf("failed to extract database: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println("GeoLite2 City database successfully updated.")
|
slog.Info("GeoLite2 City database successfully updated.")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
220
backend/internal/service/geolite_service_test.go
Normal file
220
backend/internal/service/geolite_service_test.go
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGeoLiteService_IPv6LocalRanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
localRanges string
|
||||||
|
testIP string
|
||||||
|
expectedCountry string
|
||||||
|
expectedCity string
|
||||||
|
expectError bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "IPv6 in local range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "LAN",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 not in local range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:ffff:000::1",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Multiple ranges - second range match",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||||
|
testIP: "2001:0db8:abcd:001::1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "LAN",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty local ranges",
|
||||||
|
localRanges: "",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 private address still works",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "192.168.1.1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "LAN",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 loopback",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "::1",
|
||||||
|
expectedCountry: "Internal Network",
|
||||||
|
expectedCity: "localhost",
|
||||||
|
expectError: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
|
||||||
|
defer func() {
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
service := NewGeoLiteService(&http.Client{})
|
||||||
|
|
||||||
|
country, city, err := service.GetLocationByIP(tt.testIP)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
if err == nil && country != "Internal Network" {
|
||||||
|
t.Errorf("Expected error or internal network classification for external IP")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expectedCountry, country)
|
||||||
|
assert.Equal(t, tt.expectedCity, city)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeoLiteService_isLocalIPv6(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
localRanges string
|
||||||
|
testIP string
|
||||||
|
expected bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid IPv6 in range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Valid IPv6 not in range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:ffff:000::1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 address should return false",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "192.168.1.1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "No ranges configured",
|
||||||
|
localRanges: "",
|
||||||
|
testIP: "2001:0db8:abcd:000::1",
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Edge of range",
|
||||||
|
localRanges: "2001:0db8:abcd:000::/56",
|
||||||
|
testIP: "2001:0db8:abcd:00ff:ffff:ffff:ffff:ffff",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = tt.localRanges
|
||||||
|
defer func() {
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
service := NewGeoLiteService(&http.Client{})
|
||||||
|
ip := net.ParseIP(tt.testIP)
|
||||||
|
if ip == nil {
|
||||||
|
t.Fatalf("Invalid test IP: %s", tt.testIP)
|
||||||
|
}
|
||||||
|
|
||||||
|
result := service.isLocalIPv6(ip)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeoLiteService_initializeIPv6LocalRanges(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
envValue string
|
||||||
|
expectError bool
|
||||||
|
expectCount int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "Valid IPv6 ranges",
|
||||||
|
envValue: "2001:0db8:abcd:000::/56,2001:0db8:abcd:001::/56",
|
||||||
|
expectError: false,
|
||||||
|
expectCount: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Empty environment variable",
|
||||||
|
envValue: "",
|
||||||
|
expectError: false,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Invalid CIDR notation",
|
||||||
|
envValue: "2001:0db8:abcd:000::/999",
|
||||||
|
expectError: true,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv4 range in IPv6 env var",
|
||||||
|
envValue: "192.168.1.0/24",
|
||||||
|
expectError: true,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Mixed valid and invalid ranges",
|
||||||
|
envValue: "2001:0db8:abcd:000::/56,invalid-range",
|
||||||
|
expectError: true,
|
||||||
|
expectCount: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Whitespace handling",
|
||||||
|
envValue: " 2001:0db8:abcd:000::/56 , 2001:0db8:abcd:001::/56 ",
|
||||||
|
expectError: false,
|
||||||
|
expectCount: 2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
originalConfig := common.EnvConfig.LocalIPv6Ranges
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = tt.envValue
|
||||||
|
defer func() {
|
||||||
|
common.EnvConfig.LocalIPv6Ranges = originalConfig
|
||||||
|
}()
|
||||||
|
|
||||||
|
service := &GeoLiteService{
|
||||||
|
httpClient: &http.Client{},
|
||||||
|
}
|
||||||
|
|
||||||
|
err := service.initializeIPv6LocalRanges()
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Len(t, service.localIPv6Ranges, tt.expectCount)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,25 +2,19 @@ package service
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -28,8 +22,9 @@ const (
|
|||||||
// This is a JSON file containing a key encoded as JWK
|
// This is a JSON file containing a key encoded as JWK
|
||||||
PrivateKeyFile = "jwt_private_key.json"
|
PrivateKeyFile = "jwt_private_key.json"
|
||||||
|
|
||||||
// RsaKeySize is the size, in bits, of the RSA key to generate if none is found
|
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
|
||||||
RsaKeySize = 2048
|
// This is a encrypted JSON file containing a key encoded as JWK
|
||||||
|
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
|
||||||
|
|
||||||
// KeyUsageSigning is the usage for the private keys, for the "use" property
|
// KeyUsageSigning is the usage for the private keys, for the "use" property
|
||||||
KeyUsageSigning = "sig"
|
KeyUsageSigning = "sig"
|
||||||
@@ -41,9 +36,15 @@ const (
|
|||||||
// TokenTypeClaim is the claim used to identify the type of token
|
// TokenTypeClaim is the claim used to identify the type of token
|
||||||
TokenTypeClaim = "type"
|
TokenTypeClaim = "type"
|
||||||
|
|
||||||
|
// RefreshTokenClaim is the claim used for the refresh token's value
|
||||||
|
RefreshTokenClaim = "rt"
|
||||||
|
|
||||||
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
|
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
|
||||||
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
|
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
|
||||||
|
|
||||||
|
// OAuthRefreshTokenJWTType identifies a JWT as an OAuth refresh token
|
||||||
|
OAuthRefreshTokenJWTType = "refresh-token"
|
||||||
|
|
||||||
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
|
// AccessTokenJWTType identifies a JWT as an access token used by Pocket ID
|
||||||
AccessTokenJWTType = "access-token"
|
AccessTokenJWTType = "access-token"
|
||||||
|
|
||||||
@@ -55,58 +56,74 @@ const (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type JwtService struct {
|
type JwtService struct {
|
||||||
|
envConfig *common.EnvConfigSchema
|
||||||
privateKey jwk.Key
|
privateKey jwk.Key
|
||||||
keyId string
|
keyId string
|
||||||
appConfigService *AppConfigService
|
appConfigService *AppConfigService
|
||||||
jwksEncoded []byte
|
jwksEncoded []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewJwtService(appConfigService *AppConfigService) *JwtService {
|
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
|
|
||||||
// Ensure keys are generated or loaded
|
// Ensure keys are generated or loaded
|
||||||
if err := service.init(appConfigService, common.EnvConfig.KeysPath); err != nil {
|
err := service.init(db, appConfigService, &common.EnvConfig)
|
||||||
log.Fatalf("Failed to initialize jwt service: %v", err)
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return service
|
return service, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) init(appConfigService *AppConfigService, keysPath string) error {
|
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
|
||||||
s.appConfigService = appConfigService
|
s.appConfigService = appConfigService
|
||||||
|
s.envConfig = envConfig
|
||||||
|
|
||||||
// Ensure keys are generated or loaded
|
// Ensure keys are generated or loaded
|
||||||
return s.loadOrGenerateKey(keysPath)
|
return s.loadOrGenerateKey(db)
|
||||||
}
|
}
|
||||||
|
|
||||||
// loadOrGenerateKey loads the private key from the given path or generates it if not existing.
|
func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
|
||||||
func (s *JwtService) loadOrGenerateKey(keysPath string) error {
|
// Get the key provider
|
||||||
var key jwk.Key
|
keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
||||||
|
|
||||||
// First, check if we have a JWK file
|
|
||||||
// If we do, then we just load that
|
|
||||||
jwkPath := filepath.Join(keysPath, PrivateKeyFile)
|
|
||||||
ok, err := utils.FileExists(jwkPath)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
|
return fmt.Errorf("failed to get key provider: %w", err)
|
||||||
}
|
}
|
||||||
if ok {
|
|
||||||
key, err = s.loadKeyJWK(jwkPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to load private key file (JWK) at path '%s': %w", jwkPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the key, and we are done
|
// Try loading a key
|
||||||
|
key, err := keyProvider.LoadKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we have a key, store it in the object and we're done
|
||||||
|
if key != nil {
|
||||||
err = s.SetKey(key)
|
err = s.SetKey(key)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to set private key: %w", err)
|
return fmt.Errorf("failed to set private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we are here, we need to generate a new key
|
// If we are here, we need to generate a new key
|
||||||
key, err = s.generateNewRSAKey()
|
err = s.generateKey()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to generate key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save the newly-generated key
|
||||||
|
err = keyProvider.SaveKey(s.privateKey)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to save private key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateKey generates a new key and stores it in the object
|
||||||
|
func (s *JwtService) generateKey() error {
|
||||||
|
// Default is to generate RS256 (RSA-2048) keys
|
||||||
|
key, err := jwkutils.GenerateKey(jwa.RS256().String(), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to generate new private key: %w", err)
|
return fmt.Errorf("failed to generate new private key: %w", err)
|
||||||
}
|
}
|
||||||
@@ -117,12 +134,6 @@ func (s *JwtService) loadOrGenerateKey(keysPath string) error {
|
|||||||
return fmt.Errorf("failed to set private key: %w", err)
|
return fmt.Errorf("failed to set private key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save the key as JWK
|
|
||||||
err = SaveKeyJWK(s.privateKey, jwkPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,13 +199,13 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
|||||||
Subject(user.ID).
|
Subject(user.ID).
|
||||||
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
return "", fmt.Errorf("failed to build token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetAudienceString(token, common.EnvConfig.AppURL)
|
err = SetAudienceString(token, s.envConfig.AppURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -225,8 +236,8 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
|
|||||||
jwt.WithValidate(true),
|
jwt.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithAudience(common.EnvConfig.AppURL),
|
jwt.WithAudience(s.envConfig.AppURL),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(AccessTokenJWTType)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -236,41 +247,52 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
|
// BuildIDToken creates an ID token with all claims
|
||||||
|
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Expiration(now.Add(1 * time.Hour)).
|
Expiration(now.Add(1 * time.Hour)).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
return nil, fmt.Errorf("failed to build token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetAudienceString(token, clientID)
|
err = SetAudienceString(token, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetTokenType(token, IDTokenJWTType)
|
err = SetTokenType(token, IDTokenJWTType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
for k, v := range userClaims {
|
for k, v := range userClaims {
|
||||||
err = token.Set(k, v)
|
err = token.Set(k, v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set claim '%s': %w", k, err)
|
return nil, fmt.Errorf("failed to set claim '%s': %w", k, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if nonce != "" {
|
if nonce != "" {
|
||||||
err = token.Set("nonce", nonce)
|
err = token.Set("nonce", nonce)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set claim 'nonce': %w", err)
|
return nil, fmt.Errorf("failed to set claim 'nonce': %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateIDToken creates and signs an ID token
|
||||||
|
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
|
||||||
|
token, err := s.BuildIDToken(userClaims, clientID, nonce)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
alg, _ := s.privateKey.Algorithm()
|
alg, _ := s.privateKey.Algorithm()
|
||||||
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
|
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -290,7 +312,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
|
|||||||
jwt.WithValidate(true),
|
jwt.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(IDTokenJWTType)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -313,24 +335,88 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
|
|||||||
return token, nil
|
return token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string) (string, error) {
|
// BuildOAuthAccessToken creates an OAuth access token with all claims
|
||||||
|
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jwt.Token, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Subject(user.ID).
|
Subject(user.ID).
|
||||||
Expiration(now.Add(1 * time.Hour)).
|
Expiration(now.Add(1 * time.Hour)).
|
||||||
IssuedAt(now).
|
IssuedAt(now).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(s.envConfig.AppURL).
|
||||||
|
Build()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to build token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = SetAudienceString(token, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = SetTokenType(token, OAuthAccessTokenJWTType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GenerateOAuthAccessToken creates and signs an OAuth access token
|
||||||
|
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string) (string, error) {
|
||||||
|
token, err := s.BuildOAuthAccessToken(user, clientID)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
alg, _ := s.privateKey.Algorithm()
|
||||||
|
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to sign token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return string(signed), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) VerifyOAuthAccessToken(tokenString string) (jwt.Token, error) {
|
||||||
|
alg, _ := s.privateKey.Algorithm()
|
||||||
|
token, err := jwt.ParseString(
|
||||||
|
tokenString,
|
||||||
|
jwt.WithValidate(true),
|
||||||
|
jwt.WithKey(alg, s.privateKey),
|
||||||
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
|
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return token, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *JwtService) GenerateOAuthRefreshToken(userID string, clientID string, refreshToken string) (string, error) {
|
||||||
|
now := time.Now()
|
||||||
|
token, err := jwt.NewBuilder().
|
||||||
|
Subject(userID).
|
||||||
|
Expiration(now.Add(RefreshTokenDuration)).
|
||||||
|
IssuedAt(now).
|
||||||
|
Issuer(s.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to build token: %w", err)
|
return "", fmt.Errorf("failed to build token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = token.Set(RefreshTokenClaim, refreshToken)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("failed to set 'rt' claim in token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
err = SetAudienceString(token, clientID)
|
err = SetAudienceString(token, clientID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
return "", fmt.Errorf("failed to set 'aud' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = SetTokenType(token, OAuthAccessTokenJWTType)
|
err = SetTokenType(token, OAuthRefreshTokenJWTType)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
return "", fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||||
}
|
}
|
||||||
@@ -344,21 +430,58 @@ func (s *JwtService) GenerateOauthAccessToken(user model.User, clientID string)
|
|||||||
return string(signed), nil
|
return string(signed), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) VerifyOauthAccessToken(tokenString string) (jwt.Token, error) {
|
func (s *JwtService) VerifyOAuthRefreshToken(tokenString string) (userID, clientID, rt string, err error) {
|
||||||
alg, _ := s.privateKey.Algorithm()
|
alg, _ := s.privateKey.Algorithm()
|
||||||
token, err := jwt.ParseString(
|
token, err := jwt.ParseString(
|
||||||
tokenString,
|
tokenString,
|
||||||
jwt.WithValidate(true),
|
jwt.WithValidate(true),
|
||||||
jwt.WithKey(alg, s.privateKey),
|
jwt.WithKey(alg, s.privateKey),
|
||||||
jwt.WithAcceptableSkew(clockSkew),
|
jwt.WithAcceptableSkew(clockSkew),
|
||||||
jwt.WithIssuer(common.EnvConfig.AppURL),
|
jwt.WithIssuer(s.envConfig.AppURL),
|
||||||
jwt.WithValidator(TokenTypeValidator(OAuthAccessTokenJWTType)),
|
jwt.WithValidator(TokenTypeValidator(OAuthRefreshTokenJWTType)),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to parse token: %w", err)
|
return "", "", "", fmt.Errorf("failed to parse token: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return token, nil
|
err = token.Get(RefreshTokenClaim, &rt)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", "", fmt.Errorf("failed to get '%s' claim from token: %w", RefreshTokenClaim, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
audiences, ok := token.Audience()
|
||||||
|
if !ok || len(audiences) != 1 || audiences[0] == "" {
|
||||||
|
return "", "", "", errors.New("failed to get 'aud' claim from token")
|
||||||
|
}
|
||||||
|
clientID = audiences[0]
|
||||||
|
|
||||||
|
userID, ok = token.Subject()
|
||||||
|
if !ok {
|
||||||
|
return "", "", "", errors.New("failed to get 'sub' claim from token")
|
||||||
|
}
|
||||||
|
|
||||||
|
return userID, clientID, rt, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTokenType returns the type of the JWT token issued by Pocket ID, but **does not validate it**.
|
||||||
|
func (s *JwtService) GetTokenType(tokenString string) (string, jwt.Token, error) {
|
||||||
|
// Disable validation and verification to parse the token without checking it
|
||||||
|
token, err := jwt.ParseString(
|
||||||
|
tokenString,
|
||||||
|
jwt.WithValidate(false),
|
||||||
|
jwt.WithVerify(false),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to parse token: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenType string
|
||||||
|
err = token.Get(TokenTypeClaim, &tokenType)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil, fmt.Errorf("failed to get token type claim: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenType, token, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
|
// GetPublicJWK returns the JSON Web Key (JWK) for the public key.
|
||||||
@@ -372,7 +495,7 @@ func (s *JwtService) GetPublicJWK() (jwk.Key, error) {
|
|||||||
return nil, fmt.Errorf("failed to get public key: %w", err)
|
return nil, fmt.Errorf("failed to get public key: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
EnsureAlgInKey(pubKey)
|
jwkutils.EnsureAlgInKey(pubKey, "", "")
|
||||||
|
|
||||||
return pubKey, nil
|
return pubKey, nil
|
||||||
}
|
}
|
||||||
@@ -401,107 +524,6 @@ func (s *JwtService) GetKeyAlg() (jwa.KeyAlgorithm, error) {
|
|||||||
return alg, nil
|
return alg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *JwtService) loadKeyJWK(path string) (jwk.Key, error) {
|
|
||||||
data, err := os.ReadFile(path)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to read key data: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
key, err := jwk.ParseKey(data)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to parse key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return key, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureAlgInKey ensures that the key contains an "alg" parameter, set depending on the key type
|
|
||||||
func EnsureAlgInKey(key jwk.Key) {
|
|
||||||
_, ok := key.Algorithm()
|
|
||||||
if ok {
|
|
||||||
// Algorithm is already set
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
switch key.KeyType() {
|
|
||||||
case jwa.RSA():
|
|
||||||
// Default to RS256 for RSA keys
|
|
||||||
_ = key.Set(jwk.AlgorithmKey, jwa.RS256())
|
|
||||||
case jwa.EC():
|
|
||||||
// Default to ES256 for ECDSA keys
|
|
||||||
_ = key.Set(jwk.AlgorithmKey, jwa.ES256())
|
|
||||||
case jwa.OKP():
|
|
||||||
// Default to EdDSA for OKP keys
|
|
||||||
_ = key.Set(jwk.AlgorithmKey, jwa.EdDSA())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *JwtService) generateNewRSAKey() (jwk.Key, error) {
|
|
||||||
// We generate RSA keys only
|
|
||||||
rawKey, err := rsa.GenerateKey(rand.Reader, RsaKeySize)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate RSA private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Import the raw key
|
|
||||||
return importRawKey(rawKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func importRawKey(rawKey any) (jwk.Key, error) {
|
|
||||||
key, err := jwk.Import(rawKey)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to import generated private key: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate the key ID
|
|
||||||
kid, err := generateRandomKeyID()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
|
||||||
}
|
|
||||||
_ = key.Set(jwk.KeyIDKey, kid)
|
|
||||||
|
|
||||||
// Set other required fields
|
|
||||||
_ = key.Set(jwk.KeyUsageKey, KeyUsageSigning)
|
|
||||||
EnsureAlgInKey(key)
|
|
||||||
|
|
||||||
return key, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// SaveKeyJWK saves a JWK to a file
|
|
||||||
func SaveKeyJWK(key jwk.Key, path string) error {
|
|
||||||
dir := filepath.Dir(path)
|
|
||||||
err := os.MkdirAll(dir, 0700)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create directory '%s' for key file: %w", dir, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
keyFile, err := os.OpenFile(path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create key file: %w", err)
|
|
||||||
}
|
|
||||||
defer keyFile.Close()
|
|
||||||
|
|
||||||
// Write the JSON file to disk
|
|
||||||
enc := json.NewEncoder(keyFile)
|
|
||||||
enc.SetEscapeHTML(false)
|
|
||||||
err = enc.Encode(key)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to write key file: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// generateRandomKeyID generates a random key ID.
|
|
||||||
func generateRandomKeyID() (string, error) {
|
|
||||||
buf := make([]byte, 8)
|
|
||||||
_, err := io.ReadFull(rand.Reader, buf)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("failed to read random bytes: %w", err)
|
|
||||||
}
|
|
||||||
return base64.RawURLEncoding.EncodeToString(buf), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetIsAdmin returns the value of the "isAdmin" claim in the token
|
// GetIsAdmin returns the value of the "isAdmin" claim in the token
|
||||||
func GetIsAdmin(token jwt.Token) (bool, error) {
|
func GetIsAdmin(token jwt.Token) (bool, error) {
|
||||||
if !token.Has(IsAdminClaim) {
|
if !token.Has(IsAdminClaim) {
|
||||||
@@ -509,7 +531,10 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
|
|||||||
}
|
}
|
||||||
var isAdmin bool
|
var isAdmin bool
|
||||||
err := token.Get(IsAdminClaim, &isAdmin)
|
err := token.Get(IsAdminClaim, &isAdmin)
|
||||||
return isAdmin, err
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("failed to get 'isAdmin' claim from token: %w", err)
|
||||||
|
}
|
||||||
|
return isAdmin, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetTokenType sets the "type" claim in the token
|
// SetTokenType sets the "type" claim in the token
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
|
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestJwtService_Init(t *testing.T) {
|
func TestJwtService_Init(t *testing.T) {
|
||||||
@@ -32,9 +33,16 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the JWT service
|
// Initialize the JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify the private key was set
|
// Verify the private key was set
|
||||||
@@ -65,9 +73,16 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// First create a service to generate a key
|
// First create a service to generate a key
|
||||||
firstService := &JwtService{}
|
firstService := &JwtService{}
|
||||||
err := firstService.init(mockConfig, tempDir)
|
err := firstService.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Get the key ID of the first service
|
// Get the key ID of the first service
|
||||||
@@ -76,7 +91,7 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
|
|
||||||
// Now create a new service that should load the existing key
|
// Now create a new service that should load the existing key
|
||||||
secondService := &JwtService{}
|
secondService := &JwtService{}
|
||||||
err = secondService.init(mockConfig, tempDir)
|
err = secondService.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Verify the loaded key has the same ID as the original
|
// Verify the loaded key has the same ID as the original
|
||||||
@@ -89,12 +104,19 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new JWK and save it to disk
|
// Create a new JWK and save it to disk
|
||||||
origKeyID := createECDSAKeyJWK(t, tempDir)
|
origKeyID := createECDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Now create a new service that should load the existing key
|
// Now create a new service that should load the existing key
|
||||||
svc := &JwtService{}
|
svc := &JwtService{}
|
||||||
err := svc.init(mockConfig, tempDir)
|
err := svc.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Ensure loaded key has the right algorithm
|
// Ensure loaded key has the right algorithm
|
||||||
@@ -112,12 +134,19 @@ func TestJwtService_Init(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Create a new JWK and save it to disk
|
// Create a new JWK and save it to disk
|
||||||
origKeyID := createEdDSAKeyJWK(t, tempDir)
|
origKeyID := createEdDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Now create a new service that should load the existing key
|
// Now create a new service that should load the existing key
|
||||||
svc := &JwtService{}
|
svc := &JwtService{}
|
||||||
err := svc.init(mockConfig, tempDir)
|
err := svc.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Ensure loaded key has the right algorithm and curve
|
// Ensure loaded key has the right algorithm and curve
|
||||||
@@ -146,9 +175,16 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Create a JWT service with initialized key
|
// Create a JWT service with initialized key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Get the JWK (public key)
|
// Get the JWK (public key)
|
||||||
@@ -177,12 +213,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Create an ECDSA key and save it as JWK
|
// Create an ECDSA key and save it as JWK
|
||||||
originalKeyID := createECDSAKeyJWK(t, tempDir)
|
originalKeyID := createECDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Create a JWT service that loads the ECDSA key
|
// Create a JWT service that loads the ECDSA key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Get the JWK (public key)
|
// Get the JWK (public key)
|
||||||
@@ -215,12 +258,19 @@ func TestJwtService_GetPublicJWK(t *testing.T) {
|
|||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
// Create an EdDSA key and save it as JWK
|
// Create an EdDSA key and save it as JWK
|
||||||
originalKeyID := createEdDSAKeyJWK(t, tempDir)
|
originalKeyID := createEdDSAKeyJWK(t, tempDir)
|
||||||
|
|
||||||
// Create a JWT service that loads the EdDSA key
|
// Create a JWT service that loads the EdDSA key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Get the JWK (public key)
|
// Get the JWK (public key)
|
||||||
@@ -275,16 +325,16 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates token for regular user", func(t *testing.T) {
|
t.Run("generates token for regular user", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -327,7 +377,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
t.Run("generates token for admin user", func(t *testing.T) {
|
t.Run("generates token for admin user", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test admin user
|
// Create a test admin user
|
||||||
@@ -363,7 +413,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(customMockConfig, tempDir)
|
err := service.init(nil, customMockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -398,7 +448,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -452,7 +505,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -506,7 +562,10 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -562,16 +621,16 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create test claims
|
// Create test claims
|
||||||
@@ -600,7 +659,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||||
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
|
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||||
|
|
||||||
// Check token expiration time is approximately 1 hour from now
|
// Check token expiration time is approximately 1 hour from now
|
||||||
expectedExp := time.Now().Add(1 * time.Hour)
|
expectedExp := time.Now().Add(1 * time.Hour)
|
||||||
@@ -613,7 +672,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
t.Run("can accept expired tokens if told so", func(t *testing.T) {
|
t.Run("can accept expired tokens if told so", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create test claims
|
// Create test claims
|
||||||
@@ -627,7 +686,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
// Create a token that's already expired
|
// Create a token that's already expired
|
||||||
token, err := jwt.NewBuilder().
|
token, err := jwt.NewBuilder().
|
||||||
Subject(userClaims["sub"].(string)).
|
Subject(userClaims["sub"].(string)).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(service.envConfig.AppURL).
|
||||||
Audience([]string{clientID}).
|
Audience([]string{clientID}).
|
||||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||||
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
||||||
@@ -665,13 +724,13 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
|
assert.Equal(t, userClaims["sub"], subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||||
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
|
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
|
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create test claims with nonce
|
// Create test claims with nonce
|
||||||
@@ -702,7 +761,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
|
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Generate a token with standard claims
|
// Generate a token with standard claims
|
||||||
@@ -713,7 +772,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
require.NoError(t, err, "Failed to generate ID token")
|
require.NoError(t, err, "Failed to generate ID token")
|
||||||
|
|
||||||
// Temporarily change the app URL to simulate wrong issuer
|
// Temporarily change the app URL to simulate wrong issuer
|
||||||
common.EnvConfig.AppURL = "https://wrong-issuer.com"
|
service.envConfig.AppURL = "https://wrong-issuer.com"
|
||||||
|
|
||||||
// Verify should fail due to issuer mismatch
|
// Verify should fail due to issuer mismatch
|
||||||
_, err = service.VerifyIdToken(tokenString, false)
|
_, err = service.VerifyIdToken(tokenString, false)
|
||||||
@@ -730,7 +789,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -761,7 +823,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
|
assert.Equal(t, "eddsauser456", subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||||
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
|
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||||
|
|
||||||
// Verify the key type is OKP
|
// Verify the key type is OKP
|
||||||
publicKey, err := service.GetPublicJWK()
|
publicKey, err := service.GetPublicJWK()
|
||||||
@@ -783,7 +845,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -794,7 +859,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
// Create test claims
|
// Create test claims
|
||||||
userClaims := map[string]interface{}{
|
userClaims := map[string]interface{}{
|
||||||
"sub": "ecdsauser456",
|
"sub": "ecdsauser456",
|
||||||
"name": "ECDSA User",
|
|
||||||
"email": "ecdsauser@example.com",
|
"email": "ecdsauser@example.com",
|
||||||
}
|
}
|
||||||
const clientID = "ecdsa-client-123"
|
const clientID = "ecdsa-client-123"
|
||||||
@@ -814,7 +878,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
|
assert.Equal(t, "ecdsauser456", subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||||
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
|
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||||
|
|
||||||
// Verify the key type is EC
|
// Verify the key type is EC
|
||||||
publicKey, err := service.GetPublicJWK()
|
publicKey, err := service.GetPublicJWK()
|
||||||
@@ -836,7 +900,10 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -867,21 +934,11 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
|||||||
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
|
assert.Equal(t, "rsauser456", subject, "Token subject should match user ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||||
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
|
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||||
|
|
||||||
// Verify the key type is RSA
|
|
||||||
publicKey, err := service.GetPublicJWK()
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.Equal(t, jwa.RSA().String(), publicKey.KeyType().String(), "Key type should be RSA")
|
|
||||||
|
|
||||||
// Verify the algorithm is RS256
|
|
||||||
alg, ok := publicKey.Algorithm()
|
|
||||||
require.True(t, ok)
|
|
||||||
assert.Equal(t, jwa.RS256().String(), alg.String(), "Algorithm should be RS256")
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||||
// Create a temporary directory for the test
|
// Create a temporary directory for the test
|
||||||
tempDir := t.TempDir()
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
@@ -891,16 +948,16 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
// Setup the environment variable required by the token verification
|
// Setup the environment variable required by the token verification
|
||||||
originalAppURL := common.EnvConfig.AppURL
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
common.EnvConfig.AppURL = "https://test.example.com"
|
AppURL: "https://test.example.com",
|
||||||
defer func() {
|
KeysStorage: "file",
|
||||||
common.EnvConfig.AppURL = originalAppURL
|
KeysPath: tempDir,
|
||||||
}()
|
}
|
||||||
|
|
||||||
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
|
t.Run("generates and verifies OAuth access token with standard claims", func(t *testing.T) {
|
||||||
// Create a JWT service
|
// Create a JWT service
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -913,12 +970,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
const clientID = "test-client-123"
|
const clientID = "test-client-123"
|
||||||
|
|
||||||
// Generate a token
|
// Generate a token
|
||||||
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
|
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||||
|
|
||||||
// Verify the token
|
// Verify the token
|
||||||
claims, err := service.VerifyOauthAccessToken(tokenString)
|
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||||
require.NoError(t, err, "Failed to verify generated OAuth access token")
|
require.NoError(t, err, "Failed to verify generated OAuth access token")
|
||||||
|
|
||||||
// Check the claims
|
// Check the claims
|
||||||
@@ -930,7 +987,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
assert.Equal(t, []string{clientID}, audience, "Audience should contain the client ID")
|
||||||
issuer, ok := claims.Issuer()
|
issuer, ok := claims.Issuer()
|
||||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||||
assert.Equal(t, common.EnvConfig.AppURL, issuer, "Issuer should match app URL")
|
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||||
|
|
||||||
// Check token expiration time is approximately 1 hour from now
|
// Check token expiration time is approximately 1 hour from now
|
||||||
expectedExp := time.Now().Add(1 * time.Hour)
|
expectedExp := time.Now().Add(1 * time.Hour)
|
||||||
@@ -943,7 +1000,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
t.Run("fails verification for expired token", func(t *testing.T) {
|
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||||
// Create a JWT service with a mock function to generate an expired token
|
// Create a JWT service with a mock function to generate an expired token
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -960,7 +1017,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
||||||
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||||
Audience([]string{clientID}).
|
Audience([]string{clientID}).
|
||||||
Issuer(common.EnvConfig.AppURL).
|
Issuer(service.envConfig.AppURL).
|
||||||
Build()
|
Build()
|
||||||
require.NoError(t, err, "Failed to build token")
|
require.NoError(t, err, "Failed to build token")
|
||||||
|
|
||||||
@@ -971,7 +1028,7 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
require.NoError(t, err, "Failed to sign token")
|
require.NoError(t, err, "Failed to sign token")
|
||||||
|
|
||||||
// Verify should fail due to expiration
|
// Verify should fail due to expiration
|
||||||
_, err = service.VerifyOauthAccessToken(string(signed))
|
_, err = service.VerifyOAuthAccessToken(string(signed))
|
||||||
require.Error(t, err, "Verification should fail with expired token")
|
require.Error(t, err, "Verification should fail with expired token")
|
||||||
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
|
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
|
||||||
})
|
})
|
||||||
@@ -979,11 +1036,17 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||||
// Create two JWT services with different keys
|
// Create two JWT services with different keys
|
||||||
service1 := &JwtService{}
|
service1 := &JwtService{}
|
||||||
err := service1.init(mockConfig, t.TempDir()) // Use a different temp dir
|
err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: t.TempDir(), // Use a different temp dir
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize first JWT service")
|
require.NoError(t, err, "Failed to initialize first JWT service")
|
||||||
|
|
||||||
service2 := &JwtService{}
|
service2 := &JwtService{}
|
||||||
err = service2.init(mockConfig, t.TempDir()) // Use a different temp dir
|
err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: t.TempDir(), // Use a different temp dir
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize second JWT service")
|
require.NoError(t, err, "Failed to initialize second JWT service")
|
||||||
|
|
||||||
// Create a test user
|
// Create a test user
|
||||||
@@ -995,11 +1058,11 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
const clientID = "test-client-789"
|
const clientID = "test-client-789"
|
||||||
|
|
||||||
// Generate a token with the first service
|
// Generate a token with the first service
|
||||||
tokenString, err := service1.GenerateOauthAccessToken(user, clientID)
|
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID)
|
||||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||||
|
|
||||||
// Verify with the second service should fail due to different keys
|
// Verify with the second service should fail due to different keys
|
||||||
_, err = service2.VerifyOauthAccessToken(tokenString)
|
_, err = service2.VerifyOAuthAccessToken(tokenString)
|
||||||
require.Error(t, err, "Verification should fail with invalid signature")
|
require.Error(t, err, "Verification should fail with invalid signature")
|
||||||
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
|
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
|
||||||
})
|
})
|
||||||
@@ -1013,7 +1076,10 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -1031,12 +1097,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
const clientID = "eddsa-oauth-client"
|
const clientID = "eddsa-oauth-client"
|
||||||
|
|
||||||
// Generate a token
|
// Generate a token
|
||||||
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
|
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||||
|
|
||||||
// Verify the token
|
// Verify the token
|
||||||
claims, err := service.VerifyOauthAccessToken(tokenString)
|
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||||
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
||||||
|
|
||||||
// Check the claims
|
// Check the claims
|
||||||
@@ -1067,7 +1133,10 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -1085,12 +1154,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
const clientID = "ecdsa-oauth-client"
|
const clientID = "ecdsa-oauth-client"
|
||||||
|
|
||||||
// Generate a token
|
// Generate a token
|
||||||
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
|
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||||
|
|
||||||
// Verify the token
|
// Verify the token
|
||||||
claims, err := service.VerifyOauthAccessToken(tokenString)
|
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||||
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
||||||
|
|
||||||
// Check the claims
|
// Check the claims
|
||||||
@@ -1121,7 +1190,10 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
|
|
||||||
// Create a JWT service that loads the key
|
// Create a JWT service that loads the key
|
||||||
service := &JwtService{}
|
service := &JwtService{}
|
||||||
err := service.init(mockConfig, tempDir)
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
require.NoError(t, err, "Failed to initialize JWT service")
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
// Verify it loaded the right key
|
// Verify it loaded the right key
|
||||||
@@ -1139,12 +1211,12 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
const clientID = "rsa-oauth-client"
|
const clientID = "rsa-oauth-client"
|
||||||
|
|
||||||
// Generate a token
|
// Generate a token
|
||||||
tokenString, err := service.GenerateOauthAccessToken(user, clientID)
|
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||||
|
|
||||||
// Verify the token
|
// Verify the token
|
||||||
claims, err := service.VerifyOauthAccessToken(tokenString)
|
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||||
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
require.NoError(t, err, "Failed to verify generated OAuth access token with key")
|
||||||
|
|
||||||
// Check the claims
|
// Check the claims
|
||||||
@@ -1167,6 +1239,98 @@ func TestGenerateVerifyOauthAccessToken(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGenerateVerifyOAuthRefreshToken(t *testing.T) {
|
||||||
|
// Create a temporary directory for the test
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Initialize the JWT service with a mock AppConfigService
|
||||||
|
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
||||||
|
|
||||||
|
// Setup the environment variable required by the token verification
|
||||||
|
mockEnvConfig := &common.EnvConfigSchema{
|
||||||
|
AppURL: "https://test.example.com",
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("generates and verifies refresh token", func(t *testing.T) {
|
||||||
|
// Create a JWT service
|
||||||
|
service := &JwtService{}
|
||||||
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
|
// Create a test user
|
||||||
|
const (
|
||||||
|
userID = "user123"
|
||||||
|
clientID = "client123"
|
||||||
|
refreshToken = "rt-123"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generate a token
|
||||||
|
tokenString, err := service.GenerateOAuthRefreshToken(userID, clientID, refreshToken)
|
||||||
|
require.NoError(t, err, "Failed to generate refresh token")
|
||||||
|
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||||
|
|
||||||
|
// Verify the token
|
||||||
|
resUser, resClient, resRT, err := service.VerifyOAuthRefreshToken(tokenString)
|
||||||
|
require.NoError(t, err, "Failed to verify generated token")
|
||||||
|
assert.Equal(t, userID, resUser, "Should return correct user ID")
|
||||||
|
assert.Equal(t, clientID, resClient, "Should return correct client ID")
|
||||||
|
assert.Equal(t, refreshToken, resRT, "Should return correct refresh token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||||
|
// Create a JWT service
|
||||||
|
service := &JwtService{}
|
||||||
|
err := service.init(nil, mockConfig, mockEnvConfig)
|
||||||
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
|
// Generate a token using JWT directly to create an expired token
|
||||||
|
token, err := jwt.NewBuilder().
|
||||||
|
Subject("user789").
|
||||||
|
Expiration(time.Now().Add(-1 * time.Hour)). // Expired 1 hour ago
|
||||||
|
IssuedAt(time.Now().Add(-2 * time.Hour)).
|
||||||
|
Audience([]string{"client123"}).
|
||||||
|
Issuer(service.envConfig.AppURL).
|
||||||
|
Build()
|
||||||
|
require.NoError(t, err, "Failed to build token")
|
||||||
|
|
||||||
|
signed, err := jwt.Sign(token, jwt.WithKey(jwa.RS256(), service.privateKey))
|
||||||
|
require.NoError(t, err, "Failed to sign token")
|
||||||
|
|
||||||
|
// Verify should fail due to expiration
|
||||||
|
_, _, _, err = service.VerifyOAuthRefreshToken(string(signed))
|
||||||
|
require.Error(t, err, "Verification should fail with expired token")
|
||||||
|
assert.Contains(t, err.Error(), `"exp" not satisfied`, "Error message should indicate token verification failure")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("fails verification with invalid signature", func(t *testing.T) {
|
||||||
|
// Create two JWT services with different keys
|
||||||
|
service1 := &JwtService{}
|
||||||
|
err := service1.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: t.TempDir(), // Use a different temp dir
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to initialize first JWT service")
|
||||||
|
|
||||||
|
service2 := &JwtService{}
|
||||||
|
err = service2.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: t.TempDir(), // Use a different temp dir
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to initialize second JWT service")
|
||||||
|
|
||||||
|
// Generate a token with the first service
|
||||||
|
tokenString, err := service1.GenerateOAuthRefreshToken("user789", "client123", "my-rt-123")
|
||||||
|
require.NoError(t, err, "Failed to generate refresh token")
|
||||||
|
|
||||||
|
// Verify with the second service should fail due to different keys
|
||||||
|
_, _, _, err = service2.VerifyOAuthRefreshToken(tokenString)
|
||||||
|
require.Error(t, err, "Verification should fail with invalid signature")
|
||||||
|
assert.Contains(t, err.Error(), "verification error", "Error message should indicate token verification failure")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestTokenTypeValidator(t *testing.T) {
|
func TestTokenTypeValidator(t *testing.T) {
|
||||||
// Create a context for the validator function
|
// Create a context for the validator function
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
@@ -1212,16 +1376,125 @@ func TestTokenTypeValidator(t *testing.T) {
|
|||||||
require.Error(t, err, "Validator should reject token without type claim")
|
require.Error(t, err, "Validator should reject token without type claim")
|
||||||
assert.Contains(t, err.Error(), "failed to get token type claim")
|
assert.Contains(t, err.Error(), "failed to get token type claim")
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetTokenType(t *testing.T) {
|
||||||
|
// Create a temporary directory for the test
|
||||||
|
tempDir := t.TempDir()
|
||||||
|
|
||||||
|
// Initialize the JWT service
|
||||||
|
mockConfig := NewTestAppConfigService(&model.AppConfig{})
|
||||||
|
service := &JwtService{}
|
||||||
|
err := service.init(nil, mockConfig, &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: tempDir,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to initialize JWT service")
|
||||||
|
|
||||||
|
buildTokenForType := func(t *testing.T, typ string, setClaimsFn func(b *jwt.Builder)) string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
b := jwt.NewBuilder()
|
||||||
|
b.Subject("user123")
|
||||||
|
if setClaimsFn != nil {
|
||||||
|
setClaimsFn(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := b.Build()
|
||||||
|
require.NoError(t, err, "Failed to build token")
|
||||||
|
|
||||||
|
err = SetTokenType(token, typ)
|
||||||
|
require.NoError(t, err, "Failed to set token type")
|
||||||
|
|
||||||
|
alg, _ := service.privateKey.Algorithm()
|
||||||
|
signed, err := jwt.Sign(token, jwt.WithKey(alg, service.privateKey))
|
||||||
|
require.NoError(t, err, "Failed to sign token")
|
||||||
|
|
||||||
|
return string(signed)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("correctly identifies access tokens", func(t *testing.T) {
|
||||||
|
tokenString := buildTokenForType(t, AccessTokenJWTType, nil)
|
||||||
|
|
||||||
|
// Get the token type without validating
|
||||||
|
tokenType, _, err := service.GetTokenType(tokenString)
|
||||||
|
require.NoError(t, err, "GetTokenType should not return an error")
|
||||||
|
assert.Equal(t, AccessTokenJWTType, tokenType, "Token type should be correctly identified as access token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("correctly identifies ID tokens", func(t *testing.T) {
|
||||||
|
tokenString := buildTokenForType(t, IDTokenJWTType, nil)
|
||||||
|
|
||||||
|
// Get the token type without validating
|
||||||
|
tokenType, _, err := service.GetTokenType(tokenString)
|
||||||
|
require.NoError(t, err, "GetTokenType should not return an error")
|
||||||
|
assert.Equal(t, IDTokenJWTType, tokenType, "Token type should be correctly identified as ID token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("correctly identifies OAuth access tokens", func(t *testing.T) {
|
||||||
|
tokenString := buildTokenForType(t, OAuthAccessTokenJWTType, nil)
|
||||||
|
|
||||||
|
// Get the token type without validating
|
||||||
|
tokenType, _, err := service.GetTokenType(tokenString)
|
||||||
|
require.NoError(t, err, "GetTokenType should not return an error")
|
||||||
|
assert.Equal(t, OAuthAccessTokenJWTType, tokenType, "Token type should be correctly identified as OAuth access token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("correctly identifies refresh tokens", func(t *testing.T) {
|
||||||
|
tokenString := buildTokenForType(t, OAuthRefreshTokenJWTType, nil)
|
||||||
|
|
||||||
|
// Get the token type without validating
|
||||||
|
tokenType, _, err := service.GetTokenType(tokenString)
|
||||||
|
require.NoError(t, err, "GetTokenType should not return an error")
|
||||||
|
assert.Equal(t, OAuthRefreshTokenJWTType, tokenType, "Token type should be correctly identified as refresh token")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("works with expired tokens", func(t *testing.T) {
|
||||||
|
tokenString := buildTokenForType(t, AccessTokenJWTType, func(b *jwt.Builder) {
|
||||||
|
b.Expiration(time.Now().Add(-1 * time.Hour)) // Expired 1 hour ago
|
||||||
|
})
|
||||||
|
|
||||||
|
// Get the token type without validating
|
||||||
|
tokenType, _, err := service.GetTokenType(tokenString)
|
||||||
|
require.NoError(t, err, "GetTokenType should not return an error for expired tokens")
|
||||||
|
assert.Equal(t, AccessTokenJWTType, tokenType, "Token type should be correctly identified even for expired tokens")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error for malformed tokens", func(t *testing.T) {
|
||||||
|
// Try to get the token type of a malformed token
|
||||||
|
tokenType, _, err := service.GetTokenType("not.a.valid.jwt.token")
|
||||||
|
require.Error(t, err, "GetTokenType should return an error for malformed tokens")
|
||||||
|
assert.Empty(t, tokenType, "Token type should be empty for malformed tokens")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("returns error for tokens without type claim", func(t *testing.T) {
|
||||||
|
// Create a token without type claim
|
||||||
|
tokenString := buildTokenForType(t, "", nil)
|
||||||
|
|
||||||
|
// Get the token type without validating
|
||||||
|
tokenType, _, err := service.GetTokenType(tokenString)
|
||||||
|
require.Error(t, err, "GetTokenType should return an error for tokens without type claim")
|
||||||
|
assert.Empty(t, tokenType, "Token type should be empty when type claim is missing")
|
||||||
|
assert.Contains(t, err.Error(), "failed to get token type claim", "Error message should indicate missing token type claim")
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func importKey(t *testing.T, privateKeyRaw any, path string) string {
|
func importKey(t *testing.T, privateKeyRaw any, path string) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
privateKey, err := importRawKey(privateKeyRaw)
|
privateKey, err := jwkutils.ImportRawKey(privateKeyRaw, "", "")
|
||||||
require.NoError(t, err, "Failed to import private key")
|
require.NoError(t, err, "Failed to import private key")
|
||||||
|
|
||||||
err = SaveKeyJWK(privateKey, filepath.Join(path, PrivateKeyFile))
|
keyProvider := &jwkutils.KeyProviderFile{}
|
||||||
|
err = keyProvider.Init(jwkutils.KeyProviderOpts{
|
||||||
|
EnvConfig: &common.EnvConfigSchema{
|
||||||
|
KeysStorage: "file",
|
||||||
|
KeysPath: path,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "Failed to init file key provider")
|
||||||
|
|
||||||
|
err = keyProvider.SaveKey(privateKey)
|
||||||
require.NoError(t, err, "Failed to save key")
|
require.NoError(t, err, "Failed to save key")
|
||||||
|
|
||||||
kid, _ := privateKey.KeyID()
|
kid, _ := privateKey.KeyID()
|
||||||
|
|||||||
@@ -8,17 +8,21 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"github.com/go-ldap/ldap/v3"
|
"github.com/go-ldap/ldap/v3"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"golang.org/x/text/unicode/norm"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type LdapService struct {
|
type LdapService struct {
|
||||||
@@ -122,11 +126,11 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
||||||
|
|
||||||
for _, value := range result.Entries {
|
for _, value := range result.Entries {
|
||||||
ldapId := value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
|
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||||
|
|
||||||
// Skip groups without a valid LDAP ID
|
// Skip groups without a valid LDAP ID
|
||||||
if ldapId == "" {
|
if ldapId == "" {
|
||||||
log.Printf("Skipping LDAP group without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeGroupUniqueIdentifier.Value)
|
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -164,17 +168,19 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
|
|
||||||
userResult, err := client.Search(userSearchReq)
|
userResult, err := client.Search(userSearchReq)
|
||||||
if err != nil || len(userResult.Entries) == 0 {
|
if err != nil || len(userResult.Entries) == 0 {
|
||||||
log.Printf("Could not resolve group member DN '%s': %v", member, err)
|
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||||
if username == "" {
|
if username == "" {
|
||||||
log.Printf("Could not extract username from group member DN '%s'", member)
|
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
username = norm.NFC.String(username)
|
||||||
|
|
||||||
var databaseUser model.User
|
var databaseUser model.User
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
@@ -194,7 +200,14 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
|||||||
syncGroup := dto.UserGroupCreateDto{
|
syncGroup := dto.UserGroupCreateDto{
|
||||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||||
LdapID: value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.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 == "" {
|
if databaseGroup.ID == "" {
|
||||||
@@ -245,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)
|
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Deleted group '%s'", group.Name)
|
slog.Info("Deleted group", slog.String("group", group.Name))
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
@@ -286,11 +299,11 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
||||||
|
|
||||||
for _, value := range result.Entries {
|
for _, value := range result.Entries {
|
||||||
ldapId := value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value)
|
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||||
|
|
||||||
// Skip users without a valid LDAP ID
|
// Skip users without a valid LDAP ID
|
||||||
if ldapId == "" {
|
if ldapId == "" {
|
||||||
log.Printf("Skipping LDAP user without a valid unique identifier (attribute: %s)", dbConfig.LdapAttributeUserUniqueIdentifier.Value)
|
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -306,7 +319,6 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
|
|
||||||
// If a user is found (even if disabled), enable them since they're now back in LDAP
|
// If a user is found (even if disabled), enable them since they're now back in LDAP
|
||||||
if databaseUser.ID != "" && databaseUser.Disabled {
|
if databaseUser.ID != "" && databaseUser.Disabled {
|
||||||
// Use the transaction instead of the direct context
|
|
||||||
err = tx.
|
err = tx.
|
||||||
WithContext(ctx).
|
WithContext(ctx).
|
||||||
Model(&model.User{}).
|
Model(&model.User{}).
|
||||||
@@ -315,7 +327,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
Error
|
Error
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("Failed to enable user %s: %v", databaseUser.Username, err)
|
return fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,11 +353,18 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
IsAdmin: isAdmin,
|
IsAdmin: isAdmin,
|
||||||
LdapID: ldapId,
|
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 == "" {
|
if databaseUser.ID == "" {
|
||||||
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
|
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
|
||||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||||
log.Printf("Skipping creating LDAP user '%s': %v", newUser.Username, err)
|
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
|
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
|
||||||
@@ -353,7 +372,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
} else {
|
} else {
|
||||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
|
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
|
||||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||||
log.Printf("Skipping updating LDAP user '%s': %v", newUser.Username, err)
|
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||||
continue
|
continue
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
|
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
|
||||||
@@ -366,7 +385,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
|
err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// This is not a fatal error
|
// This is not a fatal error
|
||||||
log.Printf("Error saving profile picture for user %s: %v", newUser.Username, err)
|
slog.Warn("Error saving profile picture for user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +414,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
|
return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Disabled user '%s'", user.Username)
|
slog.Info("Disabled user", slog.String("username", user.Username))
|
||||||
} else {
|
} else {
|
||||||
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
|
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
|
||||||
target := &common.LdapUserUpdateError{}
|
target := &common.LdapUserUpdateError{}
|
||||||
@@ -405,7 +424,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
|||||||
return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("Deleted user '%s'", user.Username)
|
slog.Info("Deleted user", slog.String("username", user.Username))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -468,3 +487,21 @@ func getDNProperty(property string, str string) string {
|
|||||||
// CN not found, return an empty string
|
// CN not found, return an empty string
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// convertLdapIdToString converts LDAP IDs to valid UTF-8 strings.
|
||||||
|
// LDAP servers may return binary UUIDs (16 bytes) or other non-UTF-8 data.
|
||||||
|
func convertLdapIdToString(ldapId string) string {
|
||||||
|
if utf8.ValidString(ldapId) {
|
||||||
|
return norm.NFC.String(ldapId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse as binary UUID (16 bytes)
|
||||||
|
if len(ldapId) == 16 {
|
||||||
|
if parsedUUID, err := uuid.FromBytes([]byte(ldapId)); err == nil {
|
||||||
|
return parsedUUID.String()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// As a last resort, encode as base64 to make it UTF-8 safe
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(ldapId))
|
||||||
|
}
|
||||||
|
|||||||
@@ -71,3 +71,36 @@ func TestGetDNProperty(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestConvertLdapIdToString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid UTF-8 string",
|
||||||
|
input: "simple-utf8-id",
|
||||||
|
expected: "simple-utf8-id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "binary UUID (16 bytes)",
|
||||||
|
input: string([]byte{0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0, 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf1}),
|
||||||
|
expected: "12345678-9abc-def0-1234-56789abcdef1",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-UTF8, non-UUID returns base64",
|
||||||
|
input: string([]byte{0xff, 0xfe, 0xfd, 0xfc}),
|
||||||
|
expected: "//79/A==",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := convertLdapIdToString(tt.input)
|
||||||
|
if got != tt.expected {
|
||||||
|
t.Errorf("Expected %q, got %q", tt.expected, got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user