mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-06 13:23:01 +03:00
Compare commits
290 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
24889f9ebc | ||
|
|
e0ec607198 | ||
|
|
d29fca155e | ||
|
|
e2e26b53b3 | ||
|
|
948efbd9c1 | ||
|
|
f03b80f9d7 | ||
|
|
38d7ee4432 | ||
|
|
f66e8e8b44 | ||
|
|
ee133dbceb | ||
|
|
68e4b67bd2 | ||
|
|
553147a1c0 | ||
|
|
31ae8cac96 | ||
|
|
7691622274 | ||
|
|
5d1be367c3 | ||
|
|
ed0e566e99 | ||
|
|
2793eb4ebd | ||
|
|
059073d4c2 | ||
|
|
e19b33fc2e | ||
|
|
cbe7aa6eec | ||
|
|
2a457ac8e9 | ||
|
|
0d4d5386c7 | ||
|
|
f820fc8301 | ||
|
|
131f470757 | ||
|
|
6c35570e78 | ||
|
|
869c4c5871 | ||
|
|
a65c0b3da3 | ||
|
|
3042de2ce1 | ||
|
|
f57c8d347e | ||
|
|
5b3ff7b879 | ||
|
|
9fff6ec3b6 | ||
|
|
ca5e754aea | ||
|
|
ebcf861aa6 | ||
|
|
966a566ade | ||
|
|
5fa15f6098 | ||
|
|
53f212fd3a | ||
|
|
21cb3310d6 | ||
|
|
4dc0b2f37f | ||
|
|
ac6df536ef | ||
|
|
c37386f8b2 | ||
|
|
c3a03db8b0 | ||
|
|
28c85990ba | ||
|
|
05b443d984 | ||
|
|
8326bfd136 | ||
|
|
b2e89934de | ||
|
|
c726c1621b | ||
|
|
b71c84c355 | ||
|
|
3896b7bb3b | ||
|
|
cb2a9f9f7d | ||
|
|
35b227cd17 | ||
|
|
f8a7467ec0 | ||
|
|
bf710aec56 | ||
|
|
005702e5b6 | ||
|
|
66d47bf933 | ||
|
|
d6104bbb35 | ||
|
|
a0e22036c8 | ||
|
|
21bf49c061 | ||
|
|
a408ef797b | ||
|
|
f1154257c5 | ||
|
|
0ca78bef8d |
@@ -1,32 +1,21 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/typescript-node
|
||||
{
|
||||
"name": "pocket-id",
|
||||
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {},
|
||||
"ghcr.io/devcontainers-extra/features/caddy:1": {}
|
||||
"ghcr.io/devcontainers/features/go:1": {}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"golang.go",
|
||||
"svelte.svelte-vscode"
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
},
|
||||
// Use 'postCreateCommand' to run commands after the container is created.
|
||||
// Install npm dependencies for the frontend.
|
||||
"postCreateCommand": "npm install --prefix frontend"
|
||||
|
||||
|
||||
// Features to add to the dev container. More info: https://containers.dev/features.
|
||||
// "features": {},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Configure tool-specific properties.
|
||||
// "customizations": {},
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
"containerEnv": {
|
||||
"HOST": "0.0.0.0"
|
||||
},
|
||||
"postCreateCommand": "npm install --prefix frontend && cd backend && go mod download"
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
node_modules
|
||||
**/node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
@@ -6,6 +6,9 @@ node_modules
|
||||
/frontend/.svelte-kit
|
||||
/frontend/build
|
||||
/backend/bin
|
||||
/backend/frontend/dist
|
||||
/tests/.auth
|
||||
/tests/.report
|
||||
|
||||
|
||||
# Env
|
||||
@@ -15,4 +18,5 @@ node_modules
|
||||
|
||||
# Application specific
|
||||
data
|
||||
/scripts/development
|
||||
/scripts/development
|
||||
/backend/GeoLite2-City.mmdb
|
||||
@@ -1,5 +1,5 @@
|
||||
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
APP_URL=https://your-pocket-id-domain.com
|
||||
TRUST_PROXY=false
|
||||
MAXMIND_LICENSE_KEY=
|
||||
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"
|
||||
description: "Report something that is not working as expected"
|
||||
title: "🐛 Bug Report: "
|
||||
labels: [bug]
|
||||
type: 'Bug'
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -36,13 +36,29 @@ body:
|
||||
value: |
|
||||
### Additional Information
|
||||
- type: textarea
|
||||
id: extra-information
|
||||
id: version
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Version and Environment"
|
||||
description: "Please specify the version of Pocket ID, along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||
label: "Pocket ID Version"
|
||||
description: "Please specify the version of Pocket ID."
|
||||
placeholder: "e.g., v0.24.1"
|
||||
- type: textarea
|
||||
id: database
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "Database"
|
||||
description: "Please specify the database in use: SQLite or Postgres (including version)."
|
||||
placeholder: "e.g., SQLite or Postgres 17"
|
||||
- type: textarea
|
||||
id: environment
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: "OS and Environment"
|
||||
description: "Please include the OS, whether you're using containers (Docker, Podman, etc) along with any environment-specific configurations, such your reverse proxy, that might be relevant."
|
||||
placeholder: "e.g., Docker on Ubuntu 24.04, served using Traefik"
|
||||
- type: textarea
|
||||
id: log-files
|
||||
validations:
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
2
.github/ISSUE_TEMPLATE/feature.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🚀 Feature
|
||||
description: "Submit a proposal for a new feature"
|
||||
title: "🚀 Feature: "
|
||||
labels: [feature]
|
||||
type: 'Feature'
|
||||
body:
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
2
.github/ISSUE_TEMPLATE/language-request.yml
vendored
@@ -1,7 +1,7 @@
|
||||
name: "🌐 Language request"
|
||||
description: "You want to contribute to a language that isn't on Crowdin yet?"
|
||||
title: "🌐 Language Request: <language name in english>"
|
||||
labels: [language-request]
|
||||
type: 'Language Request'
|
||||
body:
|
||||
- type: input
|
||||
id: language-name-native
|
||||
|
||||
5
.github/workflows/backend-linter.yml
vendored
5
.github/workflows/backend-linter.yml
vendored
@@ -32,8 +32,9 @@ jobs:
|
||||
go-version-file: backend/go.mod
|
||||
|
||||
- name: Run Golangci-lint
|
||||
uses: golangci/golangci-lint-action@dec74fa03096ff515422f71d18d41307cacde373 # v7.0.0
|
||||
uses: golangci/golangci-lint-action@v8.0.0
|
||||
with:
|
||||
version: v2.0.2
|
||||
version: v2.4.0
|
||||
args: --build-tags=exclude_frontend
|
||||
working-directory: backend
|
||||
only-new-issues: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-22.04 # Using an older version because of https://github.com/actions/runner-images/issues/11471
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
steps:
|
||||
- name: checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: 'Login to GitHub Container Registry'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.repository_owner}}
|
||||
password: ${{secrets.GITHUB_TOKEN}}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
100
.github/workflows/build-next.yml
vendored
Normal file
100
.github/workflows/build-next.yml
vendored
Normal file
@@ -0,0 +1,100 @@
|
||||
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
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- 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
|
||||
198
.github/workflows/e2e-tests.yml
vendored
198
.github/workflows/e2e-tests.yml
vendored
@@ -45,130 +45,39 @@ jobs:
|
||||
path: /tmp/docker-image.tar
|
||||
retention-days: 1
|
||||
|
||||
test-sqlite:
|
||||
test:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
db: [sqlite, postgres]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
node-version: 22
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v3
|
||||
id: playwright-cache
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('frontend/package-lock.json') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: docker-image
|
||||
path: /tmp
|
||||
|
||||
- name: Load Docker image
|
||||
run: docker load -i /tmp/docker-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: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: ./frontend
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create pocket-id-network
|
||||
|
||||
- name: Setup and Configure LLDAP Server
|
||||
run: |
|
||||
chmod +x ./scripts/tests/setup-lldap.sh
|
||||
./scripts/tests/setup-lldap.sh
|
||||
|
||||
- name: Run Docker Container with Sqlite DB and LDAP
|
||||
run: |
|
||||
docker run -d --name pocket-id-sqlite \
|
||||
--network pocket-id-network \
|
||||
-p 80:80 \
|
||||
-e APP_ENV=test \
|
||||
pocket-id:test
|
||||
|
||||
docker logs -f pocket-id-sqlite &> /tmp/backend.log &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
|
||||
- name: Upload Frontend Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: playwright-report-sqlite
|
||||
path: frontend/tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
- name: Upload Backend Test Report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: backend-sqlite
|
||||
path: /tmp/backend.log
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
test-postgres:
|
||||
if: github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
permissions:
|
||||
contents: read
|
||||
actions: write
|
||||
runs-on: ubuntu-latest
|
||||
needs: build
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
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-
|
||||
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
|
||||
- name: Cache PostgreSQL Docker image
|
||||
if: matrix.db == 'postgres'
|
||||
uses: actions/cache@v3
|
||||
id: postgres-cache
|
||||
with:
|
||||
@@ -176,15 +85,14 @@ jobs:
|
||||
key: postgres-17-${{ runner.os }}
|
||||
|
||||
- name: Pull and save PostgreSQL image
|
||||
if: steps.postgres-cache.outputs.cache-hit != 'true'
|
||||
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: steps.postgres-cache.outputs.cache-hit == 'true'
|
||||
if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/postgres-image.tar
|
||||
|
||||
- name: Cache LLDAP Docker image
|
||||
uses: actions/cache@v3
|
||||
id: lldap-cache
|
||||
@@ -201,7 +109,6 @@ jobs:
|
||||
- name: Load LLDAP image from cache
|
||||
if: steps.lldap-cache.outputs.cache-hit == 'true'
|
||||
run: docker load < /tmp/lldap-image.tar
|
||||
|
||||
- name: Download Docker image artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
@@ -211,66 +118,37 @@ jobs:
|
||||
- name: Load Docker image
|
||||
run: docker load -i /tmp/docker-image.tar
|
||||
|
||||
- name: Install frontend dependencies
|
||||
working-directory: ./frontend
|
||||
run: npm ci
|
||||
- name: Install test dependencies
|
||||
run: pnpm --filter pocket-id-tests install --frozen-lockfile
|
||||
|
||||
- name: Install Playwright Browsers
|
||||
working-directory: ./frontend
|
||||
working-directory: ./tests
|
||||
if: steps.playwright-cache.outputs.cache-hit != 'true'
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Create Docker network
|
||||
run: docker network create pocket-id-network
|
||||
|
||||
- name: Start Postgres DB
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
- name: Run Docker Container (sqlite) with LDAP
|
||||
if: matrix.db == 'sqlite'
|
||||
working-directory: ./tests/setup
|
||||
run: |
|
||||
docker run -d --name pocket-id-db \
|
||||
--network pocket-id-network \
|
||||
-e POSTGRES_USER=postgres \
|
||||
-e POSTGRES_PASSWORD=postgres \
|
||||
-e POSTGRES_DB=pocket-id \
|
||||
-p 5432:5432 \
|
||||
postgres:17
|
||||
docker compose up -d
|
||||
docker compose logs -f pocket-id &> /tmp/backend.log &
|
||||
|
||||
- name: Setup and Configure LLDAP Server
|
||||
- name: Run Docker Container (postgres) with LDAP
|
||||
if: matrix.db == 'postgres'
|
||||
working-directory: ./tests/setup
|
||||
run: |
|
||||
chmod +x ./scripts/tests/setup-lldap.sh
|
||||
./scripts/tests/setup-lldap.sh
|
||||
|
||||
- name: Wait for Postgres to start
|
||||
run: |
|
||||
for i in {1..5}; do
|
||||
if docker exec pocket-id-db pg_isready -U postgres; then
|
||||
echo "Postgres is ready"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for Postgres..."
|
||||
sleep 2
|
||||
done
|
||||
|
||||
- name: Run Docker Container with Postgres DB and LDAP
|
||||
run: |
|
||||
docker run -d --name pocket-id-postgres \
|
||||
--network pocket-id-network \
|
||||
-p 80:80 \
|
||||
-e APP_ENV=test \
|
||||
-e DB_PROVIDER=postgres \
|
||||
-e DB_CONNECTION_STRING=postgresql://postgres:postgres@pocket-id-db:5432/pocket-id \
|
||||
pocket-id:test
|
||||
|
||||
docker logs -f pocket-id-postgres &> /tmp/backend.log &
|
||||
docker compose -f docker-compose-postgres.yml up -d
|
||||
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./frontend
|
||||
run: npx playwright test
|
||||
working-directory: ./tests
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Frontend Test Report
|
||||
- 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
|
||||
name: playwright-report-${{ matrix.db }}
|
||||
path: tests/.report
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
@@ -278,7 +156,7 @@ jobs:
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
|
||||
with:
|
||||
name: backend-postgres
|
||||
name: backend-${{ matrix.db }}
|
||||
path: /tmp/backend.log
|
||||
include-hidden-files: true
|
||||
retention-days: 15
|
||||
|
||||
128
.github/workflows/release.yml
vendored
Normal file
128
.github/workflows/release.yml
vendored
Normal file
@@ -0,0 +1,128 @@
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
attestations: write
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'backend/go.mod'
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
- name: Set DOCKER_IMAGE_NAME
|
||||
run: |
|
||||
# Lowercase REPO_OWNER which is required for containers
|
||||
REPO_OWNER=${{ github.repository_owner }}
|
||||
DOCKER_IMAGE_NAME="ghcr.io/${REPO_OWNER,,}/pocket-id"
|
||||
echo "DOCKER_IMAGE_NAME=${DOCKER_IMAGE_NAME}" >>${GITHUB_ENV}
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{github.repository_owner}}
|
||||
password: ${{secrets.GITHUB_TOKEN}}
|
||||
- name: Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: |
|
||||
${{ env.DOCKER_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=semver,pattern={{version}},prefix=v
|
||||
type=semver,pattern={{major}}.{{minor}},prefix=v
|
||||
type=semver,pattern={{major}},prefix=v
|
||||
- name: 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
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
- name: Build frontend
|
||||
run: pnpm --filter pocket-id-frontend build
|
||||
- name: Build binaries
|
||||
run: sh scripts/development/build-binaries.sh
|
||||
- name: Build and push container image
|
||||
uses: docker/build-push-action@v6
|
||||
id: container-build-push
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
file: Dockerfile-prebuilt
|
||||
- name: 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
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-path: 'backend/.bin/pocket-id-**'
|
||||
- name: Container image attestation
|
||||
uses: actions/attest-build-provenance@v2
|
||||
with:
|
||||
subject-name: '${{ env.DOCKER_IMAGE_NAME }}'
|
||||
subject-digest: ${{ steps.container-build-push.outputs.digest }}
|
||||
push-to-registry: true
|
||||
- name: 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
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh release upload ${{ github.ref_name }} backend/.bin/*
|
||||
|
||||
publish-release:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Mark release as published
|
||||
run: gh release edit ${{ github.ref_name }} --draft=false
|
||||
42
.github/workflows/svelte-check.yml
vendored
42
.github/workflows/svelte-check.yml
vendored
@@ -4,21 +4,21 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
- 'frontend/src/**'
|
||||
- '.github/svelte-check-matcher.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/tsconfig.json'
|
||||
- 'frontend/svelte.config.js'
|
||||
pull_request:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "frontend/src/**"
|
||||
- ".github/svelte-check-matcher.json"
|
||||
- "frontend/package.json"
|
||||
- "frontend/package-lock.json"
|
||||
- "frontend/tsconfig.json"
|
||||
- "frontend/svelte.config.js"
|
||||
- 'frontend/src/**'
|
||||
- '.github/svelte-check-matcher.json'
|
||||
- 'frontend/package.json'
|
||||
- 'frontend/package-lock.json'
|
||||
- 'frontend/tsconfig.json'
|
||||
- 'frontend/svelte.config.js'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -36,24 +36,28 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
cache: "npm"
|
||||
cache-dependency-path: frontend/package-lock.json
|
||||
node-version: 22
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: pnpm-lock.yaml
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: frontend
|
||||
run: npm ci
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
- name: Build Pocket ID Frontend
|
||||
working-directory: frontend
|
||||
run: npm run build
|
||||
run: pnpm --filter pocket-id-frontend build
|
||||
|
||||
- name: Add svelte-check problem matcher
|
||||
run: echo "::add-matcher::.github/svelte-check-matcher.json"
|
||||
|
||||
- name: Run svelte-check
|
||||
working-directory: frontend
|
||||
run: npm run check
|
||||
run: pnpm --filter pocket-id-frontend check
|
||||
|
||||
2
.github/workflows/unit-tests.yml
vendored
2
.github/workflows/unit-tests.yml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
working-directory: backend
|
||||
run: |
|
||||
set -e -o pipefail
|
||||
go test -v ./... | tee /tmp/TestResults.log
|
||||
go test -tags=exclude_frontend -v ./... | tee /tmp/TestResults.log
|
||||
- uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
with:
|
||||
|
||||
1
.github/workflows/update-aaguids.yml
vendored
1
.github/workflows/update-aaguids.yml
vendored
@@ -25,6 +25,7 @@ jobs:
|
||||
run: |
|
||||
mkdir -p backend/resources
|
||||
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
|
||||
rm data.json
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@@ -9,6 +9,8 @@ node_modules
|
||||
/frontend/.svelte-kit
|
||||
/frontend/build
|
||||
/backend/bin
|
||||
pocket-id
|
||||
/tests/test-results/*.json
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
@@ -17,7 +19,7 @@ Thumbs.db
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.development-example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
@@ -30,13 +32,15 @@ vite.config.ts.timestamp-*
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
/backend/.bin
|
||||
/pocket-id
|
||||
|
||||
# Application specific
|
||||
data
|
||||
/frontend/tests/.auth
|
||||
/frontend/tests/.report
|
||||
pocket-id-backend
|
||||
/tests/.auth
|
||||
/tests/.report
|
||||
/backend/GeoLite2-City.mmdb
|
||||
/backend/frontend/dist
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
|
||||
7
.vscode/extensions.json
vendored
7
.vscode/extensions.json
vendored
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"inlang.vs-code-extension"
|
||||
"golang.go",
|
||||
"svelte.svelte-vscode",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +1,4 @@
|
||||
{
|
||||
"go.buildTags": "e2etest"
|
||||
"go.buildTags": "e2etest",
|
||||
"prettier.documentSelectors": ["**/*.svelte"],
|
||||
}
|
||||
37
.vscode/tasks.json
vendored
37
.vscode/tasks.json
vendored
@@ -1,37 +0,0 @@
|
||||
{
|
||||
// See https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
// for the documentation about the tasks.json format
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Run Caddy",
|
||||
"type": "shell",
|
||||
"command": "caddy run --config reverse-proxy/Caddyfile",
|
||||
"isBackground": true,
|
||||
"problemMatcher": {
|
||||
"owner": "custom",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": ".",
|
||||
"file": 1,
|
||||
"location": 2,
|
||||
"message": 3
|
||||
}
|
||||
],
|
||||
"background": {
|
||||
"activeOnStart": true,
|
||||
"beginsPattern": ".*",
|
||||
"endsPattern": "Caddyfile.*"
|
||||
}
|
||||
},
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
"panel": "new"
|
||||
},
|
||||
"runOptions": {
|
||||
"runOn": "folderOpen",
|
||||
"instanceLimit": 1
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
255
CHANGELOG.md
255
CHANGELOG.md
@@ -1,3 +1,258 @@
|
||||
## [](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)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add daily heartbeat request for counting Pocket ID instances ([#578](https://github.com/pocket-id/pocket-id/issues/578)) ([e0ec607](https://github.com/pocket-id/pocket-id/commit/e0ec60719883c0230f1a16611b943d6f6f637157))
|
||||
* require user verification for passkey sign in ([68e4b67](https://github.com/pocket-id/pocket-id/commit/68e4b67bd212e31ecc20277bfd293c94bf7f3642))
|
||||
* show allowed group count on oidc client list ([#567](https://github.com/pocket-id/pocket-id/issues/567)) ([38d7ee4](https://github.com/pocket-id/pocket-id/commit/38d7ee4432e0dacc2cfbabad4bfd9336b8b84079))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* run user group count inside a transaction ([f03b80f](https://github.com/pocket-id/pocket-id/commit/f03b80f9d7f2529d8cef23ca6a742a914a4ec883))
|
||||
* use ldapAttributeUserUsername for finding group members ([#565](https://github.com/pocket-id/pocket-id/issues/565)) ([f66e8e8](https://github.com/pocket-id/pocket-id/commit/f66e8e8b4478c66ed1ada9168a272b33dbf494d0))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.53.0...v) (2025-05-24)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* serve the static frontend trough the backend (#520)
|
||||
* remove old DB env variables, and jwk migrations logic (#529)
|
||||
|
||||
### Features
|
||||
|
||||
* improve buttons styling ([c37386f](https://github.com/pocket-id/pocket-id/commit/c37386f8b2f2c64bd9e7c437879a2217846852b5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add back month and year selection for date picker ([6c35570](https://github.com/pocket-id/pocket-id/commit/6c35570e78813ca6af1bae6a0374d7483bff9824))
|
||||
* animation speed set to max of 300ms ([c726c16](https://github.com/pocket-id/pocket-id/commit/c726c1621b8bd88b20cb05263f6d10888f0af8e2))
|
||||
* authorize page doesn't load ([c3a03db](https://github.com/pocket-id/pocket-id/commit/c3a03db8b0f87cddc927481cfad2ccc391f98869))
|
||||
* custom logo not correctly loaded if UI configuration is disabled ([bf710ae](https://github.com/pocket-id/pocket-id/commit/bf710aec5625c9dcb43c83d920318a036a135bae))
|
||||
* ldap tests ([4dc0b2f](https://github.com/pocket-id/pocket-id/commit/4dc0b2f37f9a57ba1c7ea084dc2a713f283d1b14))
|
||||
* remove curly bracket from user group URL ([5fa15f6](https://github.com/pocket-id/pocket-id/commit/5fa15f60984a8f2a02f15900860c3a3097032e1b))
|
||||
* remove nested button in user group list ([f57c8d3](https://github.com/pocket-id/pocket-id/commit/f57c8d347e127027378aad8831a8e4dfebfef060))
|
||||
* show correct app name on sign out page ([131f470](https://github.com/pocket-id/pocket-id/commit/131f470757044fddd0989a76e9dc9e310f19819c))
|
||||
* trim whitespaces from string inputs ([059073d](https://github.com/pocket-id/pocket-id/commit/059073d4c24e34c142dddd4c150c384779fb51a9))
|
||||
* use pointer cursor for menu items ([f820fc8](https://github.com/pocket-id/pocket-id/commit/f820fc830161499edb0da2df334e4e473d5825ae))
|
||||
* use same color as title for description in alert ([e19b33f](https://github.com/pocket-id/pocket-id/commit/e19b33fc2e2b9dd149da1f9351aca2e839ffae04))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* remove old DB env variables, and jwk migrations logic ([#529](https://github.com/pocket-id/pocket-id/issues/529)) ([f115425](https://github.com/pocket-id/pocket-id/commit/f1154257c5a9ac5c95d81343a31c02251631b203))
|
||||
* serve the static frontend trough the backend ([#520](https://github.com/pocket-id/pocket-id/issues/520)) ([f8a7467](https://github.com/pocket-id/pocket-id/commit/f8a7467ec0e939f90d19211a0a0efc5e17a58127))
|
||||
|
||||
## [](https://github.com/pocket-id/pocket-id/compare/v0.52.0...v) (2025-05-08)
|
||||
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ Before you submit the pull request for review please ensure that
|
||||
example:
|
||||
|
||||
```
|
||||
feat(share): add password protection
|
||||
fix: hide global audit log switch for non admin users
|
||||
```
|
||||
|
||||
Where `TYPE` can be:
|
||||
@@ -28,57 +28,69 @@ Before you submit the pull request for review please ensure that
|
||||
- **refactor** - code change that neither fixes a bug nor adds a feature
|
||||
|
||||
- Your pull request has a detailed description
|
||||
- You run `npm run format` to format the code
|
||||
- You run `pnpm format` to format the code
|
||||
|
||||
## Setup project
|
||||
Pocket ID consists of a frontend, backend and a reverse proxy. There are two ways to get the development environment setup:
|
||||
## Development Environment
|
||||
|
||||
Pocket ID consists of a frontend and backend. In production the frontend gets statically served by the backend, but in development they run as separate processes to enable hot reloading.
|
||||
|
||||
There are two ways to get the development environment setup:
|
||||
|
||||
### 1. Install required tools
|
||||
|
||||
#### With Dev Containers
|
||||
|
||||
If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers) in VS Code, you don't need to install anything manually, just follow the steps below.
|
||||
|
||||
## 1. Using DevContainers
|
||||
1. Make sure you have [Dev Containers](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers) extension installed
|
||||
2. Clone and open the repo in VS Code
|
||||
3. VS Code will detect .devcontainer and will prompt you to open the folder in devcontainer
|
||||
4. If the auto prompt does not work, hit `F1` and select `Dev Containers: Open Folder in Container.`, then select the pocket-id repo root folder and it'll open in container.
|
||||
|
||||
## 2. Manual
|
||||
#### Without Dev Containers
|
||||
|
||||
### Backend
|
||||
If you don't use Dev Containers, you need to install the following tools manually:
|
||||
|
||||
The backend is built with [Gin](https://gin-gonic.com) and written in Go.
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 22
|
||||
- [Go](https://golang.org/doc/install) >= 1.25
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
#### Setup
|
||||
### 2. Setup
|
||||
|
||||
#### Backend
|
||||
|
||||
The backend is built with [Gin](https://gin-gonic.com) and written in Go. To set it up, follow these steps:
|
||||
|
||||
1. Open the `backend` folder
|
||||
2. Copy the `.env.example` file to `.env` and change the `APP_ENV` to `development`
|
||||
3. Start the backend with `go run -tags e2etest ./cmd`
|
||||
2. Copy the `.env.development-example` file to `.env` and edit the variables as needed
|
||||
3. Start the backend with `go run -tags exclude_frontend ./cmd`
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript.
|
||||
The frontend is built with [SvelteKit](https://kit.svelte.dev) and written in TypeScript. To set it up, follow these steps:
|
||||
|
||||
#### Setup
|
||||
1. Open the `pocket-id` project folder
|
||||
2. Copy the `frontend/.env.development-example` file to `frontend/.env` and edit the variables as needed
|
||||
3. Install the dependencies with `pnpm install`
|
||||
4. Start the frontend with `pnpm dev`
|
||||
|
||||
1. Open the `frontend` folder
|
||||
2. Copy the `.env.example` file to `.env`
|
||||
3. Install the dependencies with `npm install`
|
||||
4. Start the frontend with `npm run dev`
|
||||
|
||||
### Reverse Proxy
|
||||
We use [Caddy](https://caddyserver.com) as a reverse proxy. You can use any other reverse proxy if you want but you have to configure it yourself.
|
||||
|
||||
#### Setup
|
||||
Run `caddy run --config reverse-proxy/Caddyfile` in the root folder.
|
||||
|
||||
You're all set!
|
||||
|
||||
## Debugging
|
||||
1. The VS Code is currently setup to auto launch caddy on opening the folder. (Defined in [tasks.json](.vscode/tasks.json))
|
||||
2. Press `F5` to start a debug session. This will launch both frontend and backend and attach debuggers to those process. (Defined in [launch.json](.vscode/launch.json))
|
||||
You're all set! The application is now listening on `localhost:3000`. The backend gets proxied trough the frontend in development mode.
|
||||
|
||||
### Testing
|
||||
|
||||
We are using [Playwright](https://playwright.dev) for end-to-end testing.
|
||||
|
||||
If you are contributing to a new feature please ensure that you add tests for it. The tests are located in the `tests` folder at the root of the project.
|
||||
|
||||
The tests can be run like this:
|
||||
1. Start the backend normally
|
||||
2. Start the frontend in production mode with `npm run build && node --env-file=.env build/index.js`
|
||||
3. Run the tests with `npm run test`
|
||||
|
||||
1. Install the dependencies from the root of the project `pnpm install`
|
||||
|
||||
2. Visit the setup folder by running `cd tests/setup`
|
||||
|
||||
3. Start the test environment by running `docker compose up -d --build`
|
||||
|
||||
4. Go back to the test folder by running `cd ..`
|
||||
5. Run the tests with `pnpm dlx playwright test` or from the root project folder `pnpm test`
|
||||
|
||||
If you make any changes to the application, you have to rebuild the test environment by running `docker compose up -d --build` again.
|
||||
|
||||
71
Dockerfile
71
Dockerfile
@@ -1,55 +1,60 @@
|
||||
# This file uses multi-stage builds to build the application from source, including the front-end
|
||||
|
||||
# Tags passed to "go build"
|
||||
ARG BUILD_TAGS=""
|
||||
ARG VERSION="unknown"
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
WORKDIR /app/frontend
|
||||
COPY ./frontend/package*.json ./
|
||||
RUN npm ci
|
||||
COPY ./frontend ./
|
||||
RUN npm run build
|
||||
RUN npm prune --production
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
COPY pnpm-workspace.yaml pnpm-lock.yaml ./
|
||||
COPY frontend/package.json ./frontend/
|
||||
RUN pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
COPY ./frontend ./frontend/
|
||||
|
||||
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.24-alpine AS backend-builder
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
ARG BUILD_TAGS
|
||||
WORKDIR /app/backend
|
||||
WORKDIR /build
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
COPY ./backend ./
|
||||
WORKDIR /app/backend/cmd
|
||||
RUN CGO_ENABLED=1 \
|
||||
COPY --from=frontend-builder /build/frontend/dist ./frontend/dist
|
||||
COPY .version .version
|
||||
|
||||
WORKDIR /build/cmd
|
||||
RUN VERSION=$(cat /build/.version) \
|
||||
CGO_ENABLED=0 \
|
||||
GOOS=linux \
|
||||
go build \
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION}" \
|
||||
-o /app/backend/pocket-id-backend \
|
||||
.
|
||||
-tags "${BUILD_TAGS}" \
|
||||
-ldflags="-X github.com/pocket-id/pocket-id/backend/internal/common.Version=${VERSION} -buildid=${VERSION}" \
|
||||
-trimpath \
|
||||
-o /build/pocket-id \
|
||||
.
|
||||
|
||||
# Stage 3: Production Image
|
||||
FROM node:22-alpine
|
||||
# Delete default node user
|
||||
RUN deluser --remove-home node
|
||||
|
||||
RUN apk add --no-cache caddy curl su-exec
|
||||
COPY ./reverse-proxy /etc/caddy/
|
||||
|
||||
FROM alpine
|
||||
WORKDIR /app
|
||||
COPY --from=frontend-builder /app/frontend/build ./frontend/build
|
||||
COPY --from=frontend-builder /app/frontend/node_modules ./frontend/node_modules
|
||||
COPY --from=frontend-builder /app/frontend/package.json ./frontend/package.json
|
||||
|
||||
COPY --from=backend-builder /app/backend/pocket-id-backend ./backend/pocket-id-backend
|
||||
RUN apk add --no-cache curl su-exec
|
||||
|
||||
COPY ./scripts ./scripts
|
||||
RUN find ./scripts -name "*.sh" -exec chmod +x {} \;
|
||||
COPY --from=backend-builder /build/pocket-id /app/pocket-id
|
||||
COPY ./scripts/docker /app/docker
|
||||
|
||||
EXPOSE 80
|
||||
RUN chmod +x /app/pocket-id && \
|
||||
find /app/docker -name "*.sh" -exec chmod +x {} \;
|
||||
|
||||
EXPOSE 1411
|
||||
ENV APP_ENV=production
|
||||
|
||||
ENTRYPOINT ["sh", "./scripts/docker/create-user.sh"]
|
||||
CMD ["sh", "./scripts/docker/entrypoint.sh"]
|
||||
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
|
||||
|
||||
ENTRYPOINT ["sh", "/app/docker/entrypoint.sh"]
|
||||
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"]
|
||||
22
Dockerfile-prebuilt
Normal file
22
Dockerfile-prebuilt
Normal file
@@ -0,0 +1,22 @@
|
||||
# This Dockerfile embeds a pre-built binary for the given Linux architecture
|
||||
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
|
||||
|
||||
FROM alpine
|
||||
|
||||
# TARGETARCH can be "amd64" or "arm64"
|
||||
ARG TARGETARCH
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl su-exec
|
||||
|
||||
COPY ./backend/.bin/pocket-id-linux-${TARGETARCH} /app/pocket-id
|
||||
COPY ./scripts/docker /app/docker
|
||||
|
||||
EXPOSE 1411
|
||||
ENV APP_ENV=production
|
||||
|
||||
HEALTHCHECK --interval=90s --timeout=5s --start-period=10s --retries=3 CMD [ "/app/pocket-id", "healthcheck" ]
|
||||
|
||||
ENTRYPOINT ["/app/docker/entrypoint.sh"]
|
||||
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
|
||||
9
backend/.env.development-example
Normal file
9
backend/.env.development-example
Normal file
@@ -0,0 +1,9 @@
|
||||
# Sample .env file for development
|
||||
# All environment variables can be found on https://pocket-id.org/docs/configuration/environment-variables
|
||||
|
||||
APP_ENV=development
|
||||
# Set the APP_URL to the URL where the frontend is listening
|
||||
# In the development environment the backend gets proxied by the frontend
|
||||
APP_URL=http://localhost:3000
|
||||
PORT=1411
|
||||
MAXMIND_LICENSE_KEY=your_license_key
|
||||
@@ -1,10 +0,0 @@
|
||||
APP_ENV=production
|
||||
PUBLIC_APP_URL=http://localhost
|
||||
# /!\ If PUBLIC_APP_URL is not a localhost address, it must be HTTPS
|
||||
DB_PROVIDER=sqlite
|
||||
# MAXMIND_LICENSE_KEY=fixme # needed for IP geolocation in the audit log
|
||||
SQLITE_DB_PATH=data/pocket-id.db
|
||||
POSTGRES_CONNECTION_STRING=postgresql://postgres:postgres@localhost:5432/pocket-id
|
||||
UPLOAD_PATH=data/uploads
|
||||
PORT=8080
|
||||
HOST=0.0.0.0
|
||||
1
backend/.gitignore
vendored
1
backend/.gitignore
vendored
@@ -15,3 +15,4 @@
|
||||
# vendor/
|
||||
./data
|
||||
.env
|
||||
pocket-id
|
||||
@@ -1,11 +1,9 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/cmds"
|
||||
)
|
||||
|
||||
// @title Pocket ID API
|
||||
@@ -13,8 +11,5 @@ import (
|
||||
// @description.markdown
|
||||
|
||||
func main() {
|
||||
err := bootstrap.Bootstrap()
|
||||
if err != nil {
|
||||
log.Fatal(err.Error())
|
||||
}
|
||||
cmds.Execute()
|
||||
}
|
||||
|
||||
9
backend/frontend/frontend_excluded.go
Normal file
9
backend/frontend/frontend_excluded.go
Normal file
@@ -0,0 +1,9 @@
|
||||
//go:build exclude_frontend
|
||||
|
||||
package frontend
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
return ErrFrontendNotIncluded
|
||||
}
|
||||
77
backend/frontend/frontend_included.go
Normal file
77
backend/frontend/frontend_included.go
Normal file
@@ -0,0 +1,77 @@
|
||||
//go:build !exclude_frontend
|
||||
|
||||
package frontend
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
//go:embed all:dist/*
|
||||
var frontendFS embed.FS
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||
}
|
||||
|
||||
cacheMaxAge := time.Hour * 24
|
||||
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
||||
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
// Try to serve the requested file
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
|
||||
// File doesn't exist, serve index.html instead
|
||||
c.Request.URL.Path = "/"
|
||||
}
|
||||
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// FileServerWithCaching wraps http.FileServer to add caching headers
|
||||
type FileServerWithCaching struct {
|
||||
root http.FileSystem
|
||||
lastModified time.Time
|
||||
cacheMaxAge int
|
||||
lastModifiedHeaderValue string
|
||||
cacheControlHeaderValue string
|
||||
}
|
||||
|
||||
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
|
||||
return &FileServerWithCaching{
|
||||
root: root,
|
||||
lastModified: time.Now(),
|
||||
cacheMaxAge: maxAge,
|
||||
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
|
||||
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if the client has a cached version
|
||||
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
|
||||
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
||||
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
|
||||
// Client's cached version is up to date
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
||||
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
|
||||
|
||||
http.FileServer(f.root).ServeHTTP(w, r)
|
||||
}
|
||||
5
backend/frontend/shared.go
Normal file
5
backend/frontend/shared.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package frontend
|
||||
|
||||
import "errors"
|
||||
|
||||
var ErrFrontendNotIncluded = errors.New("frontend is not included")
|
||||
109
backend/go.mod
109
backend/go.mod
@@ -1,99 +1,120 @@
|
||||
module github.com/pocket-id/pocket-id/backend
|
||||
|
||||
go 1.24.0
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
|
||||
github.com/emersion/go-smtp v0.21.3
|
||||
github.com/fxamacker/cbor/v2 v2.7.0
|
||||
github.com/gin-gonic/gin v1.10.0
|
||||
github.com/go-co-op/gocron/v2 v2.15.0
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/gin-gonic/gin v1.10.1
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.16.3
|
||||
github.com/go-ldap/ldap/v3 v3.4.10
|
||||
github.com/go-playground/validator/v10 v10.25.0
|
||||
github.com/go-playground/validator/v10 v10.27.0
|
||||
github.com/go-webauthn/webauthn v0.11.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-uuid v1.0.3
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/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/oschwald/maxminddb-golang/v2 v2.0.0-beta.2
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
|
||||
github.com/samber/slog-gin v1.15.1
|
||||
github.com/spf13/cobra v1.9.1
|
||||
github.com/stretchr/testify v1.10.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0
|
||||
go.opentelemetry.io/otel v1.35.0
|
||||
go.opentelemetry.io/otel/metric v1.35.0
|
||||
go.opentelemetry.io/otel v1.37.0
|
||||
go.opentelemetry.io/otel/log v0.13.0
|
||||
go.opentelemetry.io/otel/metric v1.37.0
|
||||
go.opentelemetry.io/otel/sdk v1.35.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0
|
||||
go.opentelemetry.io/otel/trace v1.35.0
|
||||
golang.org/x/crypto v0.36.0
|
||||
golang.org/x/image v0.24.0
|
||||
golang.org/x/time v0.9.0
|
||||
gorm.io/driver/postgres v1.5.11
|
||||
gorm.io/driver/sqlite v1.5.7
|
||||
gorm.io/gorm v1.25.12
|
||||
go.opentelemetry.io/otel/trace v1.37.0
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/image v0.30.0
|
||||
golang.org/x/text v0.28.0
|
||||
golang.org/x/time v0.12.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.30.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/sonic v1.12.10 // indirect
|
||||
github.com/bytedance/sonic/loader v0.2.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/disintegration/gift v1.1.2 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||
github.com/gin-contrib/sse v1.0.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
|
||||
github.com/go-logr/logr v1.4.2 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-webauthn/x v0.1.16 // indirect
|
||||
github.com/go-webauthn/x v0.1.23 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||
github.com/google/go-tpm v0.9.3 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/google/go-tpm v0.9.5 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.2 // indirect
|
||||
github.com/jackc/pgx/v5 v5.7.5 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/jonboulle/clockwork v0.5.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // 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/v2 v2.0.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.24 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/prometheus/client_golang v1.22.0 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/robfig/cron/v3 v3.0.1 // indirect
|
||||
github.com/segmentio/asm v1.2.0 // indirect
|
||||
github.com/spf13/pflag v1.0.6 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
github.com/valyala/fastjson v1.6.4 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 // indirect
|
||||
@@ -108,19 +129,21 @@ require (
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.10.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 // indirect
|
||||
go.opentelemetry.io/otel/log v0.10.0 // indirect
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
golang.org/x/arch v0.14.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 // indirect
|
||||
golang.org/x/net v0.38.0 // indirect
|
||||
golang.org/x/sync v0.12.0 // indirect
|
||||
golang.org/x/sys v0.31.0 // indirect
|
||||
golang.org/x/text v0.23.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
|
||||
golang.org/x/net v0.43.0 // indirect
|
||||
golang.org/x/oauth2 v0.27.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
|
||||
google.golang.org/grpc v1.71.0 // indirect
|
||||
google.golang.org/protobuf v1.36.5 // indirect
|
||||
google.golang.org/protobuf v1.36.7 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.7 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.38.2 // indirect
|
||||
)
|
||||
|
||||
247
backend/go.sum
247
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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/sonic v1.12.10 h1:uVCQr6oS5669E9ZVW0HyksTLfNS7Q/9hV6IVS4nEMsI=
|
||||
github.com/bytedance/sonic v1.12.10/go.mod h1:uVvFidNmlt9+wa31S1urfwwthTWteBgG0hWuoKAXTx8=
|
||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||
github.com/bytedance/sonic/loader v0.2.3 h1:yctD0Q3v2NOGfSWPLPvG2ggA2kV6TS6s4wioyEqssH0=
|
||||
github.com/bytedance/sonic/loader v0.2.3/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.4 h1:+I4s6JRE1yGuqflzwqG+aIaMdgXIorCf5P98JnaAWa8=
|
||||
github.com/dhui/dktest v0.4.4/go.mod h1:4+22R4lgsdAXrDyaH4Nqx2JEz2hLp49MqQmm9HLCQhM=
|
||||
github.com/dhui/dktest v0.4.5 h1:uUfYBIVREmj/Rw6MvgmqNAYzTiKOHJak+enB5Di73MM=
|
||||
github.com/dhui/dktest v0.4.5/go.mod h1:tmcyeHDKagvlDrz7gDKq4UAJOLIfVZYkfD5OnHDwcCo=
|
||||
github.com/disintegration/gift v1.1.2 h1:9ZyHJr+kPamiH10FX3Pynt1AxFUob812bU9Wt4GMzhs=
|
||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec h1:YrB6aVr9touOt75I9O1SiancmR2GMg45U9UYf0gtgWg=
|
||||
@@ -43,29 +44,35 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 h1:OJyUGMJTzHTd1XQp98QTaHernxMYzRaOasRir9hUlFQ=
|
||||
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21/go.mod h1:iL2twTeMvZnrg54ZoPDNfJaJaqy0xIQFuBdrLsmspwQ=
|
||||
github.com/emersion/go-smtp v0.21.3 h1:7uVwagE8iPYE48WhNsng3RRpCUpFvNl39JGNSIyGVMY=
|
||||
github.com/emersion/go-smtp v0.21.3/go.mod h1:qm27SGYgoIPRot6ubfQ/GpiPy/g3PaZAVRxiO/sDUgQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
|
||||
github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||
github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
|
||||
github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
|
||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
|
||||
github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0 h1:Kpvo71VSihE+RImmpA+3ta5CcMhoRzMGw4dJawrj4zo=
|
||||
github.com/go-co-op/gocron/v2 v2.15.0/go.mod h1:ZF70ZwEqz0OO4RBXE1sNxnANy/zvwLcattWEFsqpKig=
|
||||
github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI=
|
||||
github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
|
||||
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
|
||||
github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
@@ -74,28 +81,38 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
|
||||
github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/go-webauthn/webauthn v0.11.2 h1:Fgx0/wlmkClTKlnOsdOQ+K5HcHDsDcYIvtYmfhEOSUc=
|
||||
github.com/go-webauthn/webauthn v0.11.2/go.mod h1:aOtudaF94pM71g3jRwTYYwQTG1KyTILTcZqN1srkmD0=
|
||||
github.com/go-webauthn/x v0.1.16 h1:EaVXZntpyHviN9ykjdRBQIw9B0Ed3LO5FW7mDiMQEa8=
|
||||
github.com/go-webauthn/x v0.1.16/go.mod h1:jhYjfwe/AVYaUs2mUXArj7vvZj+SpooQPyyQGNab+Us=
|
||||
github.com/go-webauthn/x v0.1.23 h1:9lEO0s+g8iTyz5Vszlg/rXTGrx3CjcD0RZQ1GPZCaxI=
|
||||
github.com/go-webauthn/x v0.1.23/go.mod h1:AJd3hI7NfEp/4fI6T4CHD753u91l510lglU7/NMN6+E=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2 h1:2VSCMz7x7mjyTXx3m2zPokOY82LTRgxK1yQYKo6wWQ8=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2/go.mod h1:2CM6tJvn2kqPXwnXO/d3rAQYiyoIm180VsO8PRX6Rpk=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJDs0=
|
||||
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
|
||||
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-tpm v0.9.3 h1:+yx0/anQuGzi+ssRqeD6WpXjW2L/V0dItUayO0i9sRc=
|
||||
github.com/google/go-tpm v0.9.3/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
|
||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
|
||||
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
|
||||
@@ -110,12 +127,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.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
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/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.2 h1:mLoDLV6sonKlvjIEsV56SkWNCnuNv531l94GaIzO+XI=
|
||||
github.com/jackc/pgx/v5 v5.7.2/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
|
||||
github.com/jackc/pgx/v5 v5.7.5 h1:JHGfMnQY+IEtGM63d+NGMjoRpysB2JBwDr5fsngwmJs=
|
||||
github.com/jackc/pgx/v5 v5.7.5/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -130,6 +149,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/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/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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
@@ -142,10 +163,8 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
@@ -154,18 +173,22 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
|
||||
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2 h1:Cg2gVSc9h7sz9NOByczrbUvLopQmXrfFx//N+AkAr5k=
|
||||
github.com/lestrrat-go/blackmagic v1.0.2/go.mod h1:UrEqBzIR2U6CnzVyUtfM6oZNMt/7O7Vohk2J0OGSAtU=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1 h1:pzDjP9dSONCFQC/AE3mWUnHILGiYPiMKzQIS+weKJXA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0-beta1/go.mod h1:wdsgouffPvWPEYh8t7PRH/PidR5sfVqt0na4Nhj60Ms=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1 h1:Iqjb8JvWjh34Jv8DeM2wQ1aG5fzFBzwQu7rlqwuJB0I=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.0-beta1/go.mod h1:ak32WoNtHE0aLowVWBcCvXngcAnW4tuC0YhFwOr/kwc=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0 h1:nZUx/zFg5uc2rhlu1L1DidGr5Sj02JbXvGSpnY4LMrc=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.0/go.mod h1:k2U1QIiyVqAKtkffbg+cUmsyiPGQsb9aAfNQiNFuQ9Q=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.10 h1:XuoCBhZBncRIjMQ32HdEc76rH0xK/Qv2wq5TBouYJDw=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.10/go.mod h1:kNMedLgTpHvPJkK5EMVa1JFz+UVyY2dMmZKu3qjl/Pk=
|
||||
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
|
||||
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
||||
@@ -187,14 +210,18 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2 h1:jG+FaCBv3h6GD5F+oenTfe3+0NmX8sCKjni5k3A5Dek=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.2/go.mod h1:rHaQJ5SjfCdL4sqCKa3FhklRcaXga2/qyvmQuA+ZJ6M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
|
||||
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
|
||||
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
|
||||
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8 h1:aM1/rO6p+XV+l+seD7UCtFZgsOefDTrFVLvPoZWjXZs=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8/go.mod h1:Jts8ztuE0PkUwY7VCJyp6B68ujQfr6G9P5Dn3Yx9u6w=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
@@ -207,16 +234,24 @@ github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
|
||||
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/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
|
||||
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
|
||||
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
|
||||
github.com/segmentio/asm v1.2.0/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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -224,18 +259,21 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/fastjson v1.6.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
|
||||
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
|
||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 h1:lFM7SZo8Ce01RzRfnUFQZEYeWRf/MtOA3A5MobOqk2g=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0/go.mod h1:Dw05mhFtrKAYu72Tkb3YBYeQpRUJ4quDgo2DQw3No5A=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0 h1:HY2hJ7yn3KuEBBBsKxvF3ViSmzLwsgeNvD+0utRMgzc=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.59.0/go.mod h1:H4H7vs8766kwFnOZVEGMJFVF+phpBSmTckvvNRdJeDI=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.59.0 h1:dKhAFwh7SSoOw+gwMtSv+XLkUGTFAwAGMT3X3XSE4FA=
|
||||
@@ -244,8 +282,8 @@ go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.60.0/go.mod h1:ZvRTVaYYGypytG0zRp2A60lpj//cMq3ZnxYdZaljVBM=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0 h1:TT4fX+nBOA/+LUkobKGW1ydGcn+G3vRw9+g5HwCphpk=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.54.0/go.mod h1:L7UH0GbB0p47T4Rri3uHjbpCFYrVrwc1I25QhNPiGK8=
|
||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
|
||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
|
||||
go.opentelemetry.io/otel v1.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
|
||||
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0 h1:5dTKu4I5Dn4P2hxyW3l3jTaZx9ACgg0ECos1eAVrheY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.10.0/go.mod h1:P5HcUI8obLrCCmM3sbVBohZFH34iszk/+CPWuakZWL8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.10.0 h1:q/heq5Zh8xV1+7GoMGJpTxM2Lhq5+bFxB29tshuRuw0=
|
||||
@@ -268,45 +306,49 @@ go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0 h1:PB3Zrjs1sG1GBX
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.35.0/go.mod h1:U2R3XyVPzn0WX7wOIypPuptulsMcPDPs/oiSVOMVnHY=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0 h1:T0Ec2E+3YZf5bgTNQVet8iTDW7oIk03tXHq+wkwIDnE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.35.0/go.mod h1:30v2gqH+vYGJsesLWFov8u47EpYTcIQcBjKpI6pJThg=
|
||||
go.opentelemetry.io/otel/log v0.10.0 h1:1CXmspaRITvFcjA4kyVszuG4HjA61fPDxMb7q3BuyF0=
|
||||
go.opentelemetry.io/otel/log v0.10.0/go.mod h1:PbVdm9bXKku/gL0oFfUF4wwsQsOPlpo4VEqjvxih+FM=
|
||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
|
||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
|
||||
go.opentelemetry.io/otel/log v0.13.0 h1:yoxRoIZcohB6Xf0lNv9QIyCzQvrtGZklVbdCoyb7dls=
|
||||
go.opentelemetry.io/otel/log v0.13.0/go.mod h1:INKfG4k1O9CL25BaM1qLe0zIedOpvlS5Z7XgSbmN83E=
|
||||
go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
|
||||
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
|
||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0 h1:lR4teQGWfeDVGoute6l0Ou+RpFqQ9vaPdrNJlST0bvw=
|
||||
go.opentelemetry.io/otel/sdk/log v0.10.0/go.mod h1:A+V1UTWREhWAittaQEG4bYm4gAZa6xnvVu+xKrIRkzo=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
|
||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
|
||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
|
||||
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
|
||||
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
|
||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/arch v0.14.0 h1:z9JUEZWr8x4rR0OU6c4/4t6E6jOZ8/QBS2bBYBm4tx4=
|
||||
golang.org/x/arch v0.14.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8 h1:yqrTHse8TCMW1M1ZCP+VAR/l0kKxwaAIqN/il7x4voA=
|
||||
golang.org/x/exp v0.0.0-20250106191152-7588d65b2ba8/go.mod h1:tujkw807nyEEAamNbDrEGzRav+ilXA7PCRAd6xsmwiU=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE=
|
||||
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
|
||||
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
|
||||
golang.org/x/image v0.30.0 h1:jD5RhkmVAnjqaCUXfbGBrn3lpxbknfN9w2UhHHU+5B4=
|
||||
golang.org/x/image v0.30.0/go.mod h1:SAEUTxCCMWSrJcCy/4HwavEsfZZJlYxeHLc6tTiAe/c=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
@@ -318,8 +360,11 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
|
||||
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
|
||||
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
@@ -327,8 +372,8 @@ golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
|
||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
@@ -341,8 +386,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -353,6 +398,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
@@ -361,25 +407,29 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||
golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
|
||||
golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
|
||||
golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng=
|
||||
golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a/go.mod h1:uRxBH1mhmO8PGhU89cMcHaXKZqO+OfakD8QQO0oYwlQ=
|
||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
|
||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
|
||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
||||
google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A=
|
||||
google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
@@ -387,10 +437,33 @@ gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.5.11 h1:ubBVAfbKEUld/twyKZ0IYn9rSQh448EdelLYk9Mv314=
|
||||
gorm.io/driver/postgres v1.5.11/go.mod h1:DX3GReXH+3FPWGrrgffdvCk3DQ1dwDPdmbenSkweRGI=
|
||||
gorm.io/driver/sqlite v1.5.7 h1:8NvsrhP0ifM7LX9G4zPB97NwovUakUxc+2V2uuf3Z1I=
|
||||
gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDah4=
|
||||
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
|
||||
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.30.1 h1:lSHg33jJTBxs2mgJRfRZeLDG+WZaHYCk3Wtfl6Ngzo4=
|
||||
gorm.io/gorm v1.30.1/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/cc/v4 v4.26.3 h1:yEN8dzrkRFnn4PUUKXLYIqVf2PJYAEjMTFjO3BDGc3I=
|
||||
modernc.org/cc/v4 v4.26.3/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||
modernc.org/fileutil v1.3.15 h1:rJAXTP6ilMW/1+kzDiqmBlHLWszheUFXIyGQIAvjJpY=
|
||||
modernc.org/fileutil v1.3.15/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.66.7 h1:rjhZ8OSCybKWxS1CJr0hikpEi6Vg+944Ouyrd+bQsoY=
|
||||
modernc.org/libc v1.66.7/go.mod h1:ln6tbWX0NH+mzApEoDRvilBvAWFt1HX7AUA4VDdVDPM=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
|
||||
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
"fmt"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
@@ -12,17 +12,17 @@ import (
|
||||
)
|
||||
|
||||
// initApplicationImages copies the images from the images directory to the application-images directory
|
||||
func initApplicationImages() {
|
||||
func initApplicationImages() error {
|
||||
dirPath := common.EnvConfig.UploadPath + "/application-images"
|
||||
|
||||
sourceFiles, err := resources.FS.ReadDir("images")
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
return fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
destinationFiles, err := os.ReadDir(dirPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
log.Fatalf("Error reading directory: %v", err)
|
||||
return fmt.Errorf("failed to read directory: %w", err)
|
||||
}
|
||||
|
||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||
@@ -35,9 +35,11 @@ func initApplicationImages() {
|
||||
|
||||
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
|
||||
if err != nil {
|
||||
log.Fatalf("Error copying file: %v", err)
|
||||
return fmt.Errorf("failed to copy file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool {
|
||||
|
||||
@@ -3,7 +3,7 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
@@ -11,27 +11,26 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/signals"
|
||||
)
|
||||
|
||||
func Bootstrap() error {
|
||||
// Get a context that is canceled when the application is stopping
|
||||
ctx := signals.SignalContext(context.Background())
|
||||
|
||||
initApplicationImages()
|
||||
|
||||
// Perform migrations for changes
|
||||
migrateConfigDBConnstring()
|
||||
migrateKey()
|
||||
|
||||
// Initialize the tracer and metrics exporter
|
||||
shutdownFns, httpClient, err := initOtel(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||
func Bootstrap(ctx context.Context) error {
|
||||
// Initialize the observability stack, including the logger, distributed tracing, and metrics
|
||||
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OpenTelemetry: %w", err)
|
||||
}
|
||||
slog.InfoContext(ctx, "Pocket ID is starting")
|
||||
|
||||
err = initApplicationImages()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize application images: %w", err)
|
||||
}
|
||||
|
||||
// Connect to the database
|
||||
db := newDatabase()
|
||||
db, err := NewDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize database: %w", err)
|
||||
}
|
||||
|
||||
// Create all services
|
||||
svc, err := initServices(ctx, db, httpClient)
|
||||
@@ -44,7 +43,7 @@ func Bootstrap() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create job scheduler: %w", err)
|
||||
}
|
||||
err = registerScheduledJobs(ctx, db, svc, scheduler)
|
||||
err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register scheduled jobs: %w", err)
|
||||
}
|
||||
@@ -52,7 +51,7 @@ func Bootstrap() error {
|
||||
// Init the router
|
||||
router := initRouter(db, svc)
|
||||
|
||||
// Run all background serivces
|
||||
// Run all background services
|
||||
// This call blocks until the context is canceled
|
||||
err = utils.
|
||||
NewServiceRunner(router, scheduler.Run).
|
||||
@@ -63,13 +62,14 @@ func Bootstrap() error {
|
||||
|
||||
// Invoke all shutdown functions
|
||||
// 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)
|
||||
defer shutdownCancel()
|
||||
err = utils.
|
||||
NewServiceRunner(shutdownFns...).
|
||||
Run(shutdownCtx)
|
||||
Run(shutdownCtx) //nolint:contextcheck
|
||||
if err != nil {
|
||||
log.Printf("Error shutting down services: %v", err)
|
||||
slog.Error("Error shutting down services", slog.Any("error", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// Performs the migration of the database connection string
|
||||
// See: https://github.com/pocket-id/pocket-id/pull/388
|
||||
func migrateConfigDBConnstring() {
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
// Check if we're using the deprecated SqliteDBPath env var
|
||||
if common.EnvConfig.SqliteDBPath != "" {
|
||||
connString := "file:" + common.EnvConfig.SqliteDBPath + "?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate"
|
||||
common.EnvConfig.DbConnectionString = connString
|
||||
common.EnvConfig.SqliteDBPath = ""
|
||||
|
||||
log.Printf("[WARN] Env var 'SQLITE_DB_PATH' is deprecated - use 'DB_CONNECTION_STRING' instead with the value: '%s'", connString)
|
||||
}
|
||||
case common.DbProviderPostgres:
|
||||
// Check if we're using the deprecated PostgresConnectionString alias
|
||||
if common.EnvConfig.PostgresConnectionString != "" {
|
||||
common.EnvConfig.DbConnectionString = common.EnvConfig.PostgresConnectionString
|
||||
common.EnvConfig.PostgresConnectionString = ""
|
||||
|
||||
log.Print("[WARN] Env var 'POSTGRES_CONNECTION_STRING' is deprecated - use 'DB_CONNECTION_STRING' instead with the same value")
|
||||
}
|
||||
default:
|
||||
// We don't do anything here in the default case
|
||||
// This is an error, but will be handled later on
|
||||
}
|
||||
}
|
||||
@@ -3,32 +3,39 @@ package bootstrap
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
slogGorm "github.com/orandin/slog-gorm"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
gormLogger "gorm.io/gorm/logger"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func newDatabase() (db *gorm.DB) {
|
||||
db, err := connectDatabase()
|
||||
func NewDatabase() (db *gorm.DB, err error) {
|
||||
db, err = connectDatabase()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to connect to database: %v", err)
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
sqlDb, err := db.DB()
|
||||
if err != nil {
|
||||
log.Fatalf("failed to get sql.DB: %v", err)
|
||||
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Choose the correct driver for the database provider
|
||||
@@ -40,23 +47,24 @@ func newDatabase() (db *gorm.DB) {
|
||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||
default:
|
||||
// Should never happen at this point
|
||||
log.Fatalf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
if err != nil {
|
||||
log.Fatalf("failed to create migration driver: %v", err)
|
||||
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := migrateDatabase(driver); err != nil {
|
||||
log.Fatalf("failed to run migrations: %v", err)
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return db
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func migrateDatabase(driver database.Driver) error {
|
||||
// Use the embedded migrations
|
||||
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider))
|
||||
// Embedded migrations via iofs
|
||||
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||
source, err := iofs.New(resources.FS, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||
}
|
||||
@@ -66,14 +74,66 @@ func migrateDatabase(driver database.Driver) error {
|
||||
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
|
||||
err = m.Up()
|
||||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||
requiredVersion, err := getRequiredMigrationVersion(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last migration version: %w", err)
|
||||
}
|
||||
|
||||
currentVersion, _, _ := m.Version()
|
||||
if currentVersion > requiredVersion {
|
||||
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
|
||||
if !common.EnvConfig.AllowDowngrade {
|
||||
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
|
||||
}
|
||||
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||
return migrateDatabaseFromGitHub(driver, requiredVersion)
|
||||
}
|
||||
|
||||
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply embedded migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
|
||||
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
|
||||
func getRequiredMigrationVersion(path string) (uint, error) {
|
||||
entries, err := resources.FS.ReadDir(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read migration directory: %w", err)
|
||||
}
|
||||
|
||||
var maxVersion uint
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
var version uint
|
||||
n, err := fmt.Sscanf(name, "%d_", &version)
|
||||
if err == nil && n == 1 {
|
||||
if version > maxVersion {
|
||||
maxVersion = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxVersion, nil
|
||||
}
|
||||
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
@@ -83,10 +143,21 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||
}
|
||||
if !strings.HasPrefix(common.EnvConfig.DbConnectionString, "file:") {
|
||||
return nil, errors.New("invalid value for env var 'DB_CONNECTION_STRING': does not begin with 'file:'")
|
||||
|
||||
sqliteutil.RegisterSqliteFunctions()
|
||||
|
||||
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
dialector = sqlite.Open(common.EnvConfig.DbConnectionString)
|
||||
|
||||
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||
err = ensureSqliteTempDir(filepath.Dir(dbPath))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dialector = sqlite.Open(connString)
|
||||
case common.DbProviderPostgres:
|
||||
if common.EnvConfig.DbConnectionString == "" {
|
||||
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||
@@ -99,37 +170,268 @@ func connectDatabase() (db *gorm.DB, err error) {
|
||||
for i := 1; i <= 3; i++ {
|
||||
db, err = gorm.Open(dialector, &gorm.Config{
|
||||
TranslateError: true,
|
||||
Logger: getLogger(),
|
||||
Logger: getGormLogger(),
|
||||
})
|
||||
if err == nil {
|
||||
slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider)))
|
||||
return db, nil
|
||||
}
|
||||
|
||||
log.Printf("Attempt %d: Failed to initialize database. Retrying...", i)
|
||||
slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
time.Sleep(3 * time.Second)
|
||||
}
|
||||
|
||||
slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err))
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
func getLogger() logger.Interface {
|
||||
isProduction := common.EnvConfig.AppEnv == "production"
|
||||
|
||||
var logLevel logger.LogLevel
|
||||
if isProduction {
|
||||
logLevel = logger.Error
|
||||
} else {
|
||||
logLevel = logger.Info
|
||||
func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
|
||||
if !strings.HasPrefix(connString, "file:") {
|
||||
connString = "file:" + connString
|
||||
}
|
||||
|
||||
return logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logLevel,
|
||||
IgnoreRecordNotFoundError: isProduction,
|
||||
ParameterizedQueries: isProduction,
|
||||
Colorful: !isProduction,
|
||||
},
|
||||
)
|
||||
// Check if we're using an in-memory database
|
||||
isMemoryDB := isSqliteInMemory(connString)
|
||||
|
||||
// Parse the connection string
|
||||
connStringUrl, err := url.Parse(connString)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Convert options for the old SQLite driver to the new one
|
||||
convertSqlitePragmaArgs(connStringUrl)
|
||||
|
||||
// Add the default and required params
|
||||
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid SQLite connection string: %w", err)
|
||||
}
|
||||
|
||||
// Get the absolute path to the database
|
||||
// Here, we know for a fact that the ? is present
|
||||
parsedConnString = connStringUrl.String()
|
||||
idx := strings.IndexRune(parsedConnString, '?')
|
||||
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
|
||||
}
|
||||
|
||||
return parsedConnString, dbPath, nil
|
||||
}
|
||||
|
||||
// The official C implementation of SQLite allows some additional properties in the connection string
|
||||
// that are not supported in the in the modernc.org/sqlite driver, and which must be passed as PRAGMA args instead.
|
||||
// To ensure that people can use similar args as in the C driver, which was also used by Pocket ID
|
||||
// previously (via github.com/mattn/go-sqlite3), we are converting some options.
|
||||
// Note this function updates connStringUrl.
|
||||
func convertSqlitePragmaArgs(connStringUrl *url.URL) {
|
||||
// Reference: https://github.com/mattn/go-sqlite3?tab=readme-ov-file#connection-string
|
||||
// This only includes a subset of options, excluding those that are not relevant to us
|
||||
qs := make(url.Values, len(connStringUrl.Query()))
|
||||
for k, v := range connStringUrl.Query() {
|
||||
switch strings.ToLower(k) {
|
||||
case "_auto_vacuum", "_vacuum":
|
||||
qs.Add("_pragma", "auto_vacuum("+v[0]+")")
|
||||
case "_busy_timeout", "_timeout":
|
||||
qs.Add("_pragma", "busy_timeout("+v[0]+")")
|
||||
case "_case_sensitive_like", "_cslike":
|
||||
qs.Add("_pragma", "case_sensitive_like("+v[0]+")")
|
||||
case "_foreign_keys", "_fk":
|
||||
qs.Add("_pragma", "foreign_keys("+v[0]+")")
|
||||
case "_locking_mode", "_locking":
|
||||
qs.Add("_pragma", "locking_mode("+v[0]+")")
|
||||
case "_secure_delete":
|
||||
qs.Add("_pragma", "secure_delete("+v[0]+")")
|
||||
case "_synchronous", "_sync":
|
||||
qs.Add("_pragma", "synchronous("+v[0]+")")
|
||||
default:
|
||||
// Pass other query-string args as-is
|
||||
qs[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
// Update the connStringUrl object
|
||||
connStringUrl.RawQuery = qs.Encode()
|
||||
}
|
||||
|
||||
// Adds the default (and some required) parameters to the SQLite connection string.
|
||||
// Note this function updates connStringUrl.
|
||||
func addSqliteDefaultParameters(connStringUrl *url.URL, isMemoryDB bool) error {
|
||||
// This function include code adapted from https://github.com/dapr/components-contrib/blob/v1.14.6/
|
||||
// Copyright (C) 2023 The Dapr Authors
|
||||
// License: Apache2
|
||||
const defaultBusyTimeout = 2500 * time.Millisecond
|
||||
|
||||
// Get the "query string" from the connection string if present
|
||||
qs := connStringUrl.Query()
|
||||
if len(qs) == 0 {
|
||||
qs = make(url.Values, 2)
|
||||
}
|
||||
|
||||
// If the database is in-memory, we must ensure that cache=shared is set
|
||||
if isMemoryDB {
|
||||
qs["cache"] = []string{"shared"}
|
||||
}
|
||||
|
||||
// Check if the database is read-only or immutable
|
||||
isReadOnly := false
|
||||
if len(qs["mode"]) > 0 {
|
||||
// Keep the first value only
|
||||
qs["mode"] = []string{
|
||||
strings.ToLower(qs["mode"][0]),
|
||||
}
|
||||
if qs["mode"][0] == "ro" {
|
||||
isReadOnly = true
|
||||
}
|
||||
}
|
||||
if len(qs["immutable"]) > 0 {
|
||||
// Keep the first value only
|
||||
qs["immutable"] = []string{
|
||||
strings.ToLower(qs["immutable"][0]),
|
||||
}
|
||||
if qs["immutable"][0] == "1" {
|
||||
isReadOnly = true
|
||||
}
|
||||
}
|
||||
|
||||
// We do not want to override a _txlock if set, but we'll show a warning if it's not "immediate"
|
||||
if len(qs["_txlock"]) > 0 {
|
||||
// Keep the first value only
|
||||
qs["_txlock"] = []string{
|
||||
strings.ToLower(qs["_txlock"][0]),
|
||||
}
|
||||
if qs["_txlock"][0] != "immediate" {
|
||||
slog.Warn("SQLite connection is being created with a _txlock different from the recommended value 'immediate'")
|
||||
}
|
||||
} else {
|
||||
qs["_txlock"] = []string{"immediate"}
|
||||
}
|
||||
|
||||
// Add pragma values
|
||||
var hasBusyTimeout, hasJournalMode bool
|
||||
if len(qs["_pragma"]) == 0 {
|
||||
qs["_pragma"] = make([]string, 0, 3)
|
||||
} else {
|
||||
for _, p := range qs["_pragma"] {
|
||||
p = strings.ToLower(p)
|
||||
switch {
|
||||
case strings.HasPrefix(p, "busy_timeout"):
|
||||
hasBusyTimeout = true
|
||||
case strings.HasPrefix(p, "journal_mode"):
|
||||
hasJournalMode = true
|
||||
case strings.HasPrefix(p, "foreign_keys"):
|
||||
return errors.New("found forbidden option '_pragma=foreign_keys' in the connection string")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !hasBusyTimeout {
|
||||
qs["_pragma"] = append(qs["_pragma"], fmt.Sprintf("busy_timeout(%d)", defaultBusyTimeout.Milliseconds()))
|
||||
}
|
||||
if !hasJournalMode {
|
||||
switch {
|
||||
case isMemoryDB:
|
||||
// For in-memory databases, set the journal to MEMORY, the only allowed option besides OFF (which would make transactions ineffective)
|
||||
qs["_pragma"] = append(qs["_pragma"], "journal_mode(MEMORY)")
|
||||
case isReadOnly:
|
||||
// Set the journaling mode to "DELETE" (the default) if the database is read-only
|
||||
qs["_pragma"] = append(qs["_pragma"], "journal_mode(DELETE)")
|
||||
default:
|
||||
// Enable WAL
|
||||
qs["_pragma"] = append(qs["_pragma"], "journal_mode(WAL)")
|
||||
}
|
||||
}
|
||||
|
||||
// Forcefully enable foreign keys
|
||||
qs["_pragma"] = append(qs["_pragma"], "foreign_keys(1)")
|
||||
|
||||
// Update the connStringUrl object
|
||||
connStringUrl.RawQuery = qs.Encode()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// isSqliteInMemory returns true if the connection string is for an in-memory database.
|
||||
func isSqliteInMemory(connString string) bool {
|
||||
lc := strings.ToLower(connString)
|
||||
|
||||
// First way to define an in-memory database is to use ":memory:" or "file::memory:" as connection string
|
||||
if strings.HasPrefix(lc, ":memory:") || strings.HasPrefix(lc, "file::memory:") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Another way is to pass "mode=memory" in the "query string"
|
||||
idx := strings.IndexRune(lc, '?')
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
qs, _ := url.ParseQuery(lc[(idx + 1):])
|
||||
|
||||
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
|
||||
}
|
||||
|
||||
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
|
||||
// The default directory may not be writable when using a container with a read-only root file system
|
||||
// See: https://www.sqlite.org/tempfiles.html
|
||||
func ensureSqliteTempDir(dbPath string) error {
|
||||
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
|
||||
//
|
||||
// - The SQLITE_TMPDIR environment variable
|
||||
// - The TMPDIR environment variable
|
||||
// - /var/tmp
|
||||
// - /usr/tmp
|
||||
// - /tmp
|
||||
//
|
||||
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
|
||||
//
|
||||
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
|
||||
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
|
||||
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
|
||||
ok, err := utils.IsWritableDir(dir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
|
||||
}
|
||||
if ok {
|
||||
// We found a folder that's writable
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
|
||||
err := os.Setenv("SQLITE_TMPDIR", dbPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getGormLogger() gormLogger.Interface {
|
||||
loggerOpts := make([]slogGorm.Option, 0, 5)
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.WithSlowThreshold(200*time.Millisecond),
|
||||
slogGorm.WithErrorField("error"),
|
||||
)
|
||||
|
||||
if common.EnvConfig.AppEnv == "production" {
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
|
||||
slogGorm.WithIgnoreTrace(),
|
||||
)
|
||||
} else {
|
||||
loggerOpts = append(loggerOpts,
|
||||
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
|
||||
slogGorm.WithRecordNotFoundError(),
|
||||
slogGorm.WithTraceAll(),
|
||||
)
|
||||
}
|
||||
|
||||
return slogGorm.New(loggerOpts...)
|
||||
}
|
||||
|
||||
330
backend/internal/bootstrap/db_bootstrap_test.go
Normal file
330
backend/internal/bootstrap/db_bootstrap_test.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsSqliteInMemory(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
connStr string
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "memory database with :memory:",
|
||||
connStr: ":memory:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with file::memory:",
|
||||
connStr: "file::memory:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with :MEMORY: (uppercase)",
|
||||
connStr: ":MEMORY:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with FILE::MEMORY: (uppercase)",
|
||||
connStr: "FILE::MEMORY:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "memory database with mixed case",
|
||||
connStr: ":Memory:",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "has mode=memory",
|
||||
connStr: "file:data?mode=memory",
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "file database",
|
||||
connStr: "data.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "file database with path",
|
||||
connStr: "/path/to/data.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "file database with file: prefix",
|
||||
connStr: "file:data.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
connStr: "",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "string containing memory but not at start",
|
||||
connStr: "data:memory:.db",
|
||||
expected: false,
|
||||
},
|
||||
{
|
||||
name: "has mode=ro",
|
||||
connStr: "file:data?mode=ro",
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isSqliteInMemory(tt.connStr)
|
||||
assert.Equal(t, tt.expected, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertSqlitePragmaArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "basic file path",
|
||||
input: "file:test.db",
|
||||
expected: "file:test.db",
|
||||
},
|
||||
{
|
||||
name: "converts _busy_timeout to pragma",
|
||||
input: "file:test.db?_busy_timeout=5000",
|
||||
expected: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||
},
|
||||
{
|
||||
name: "converts _timeout to pragma",
|
||||
input: "file:test.db?_timeout=5000",
|
||||
expected: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||
},
|
||||
{
|
||||
name: "converts _foreign_keys to pragma",
|
||||
input: "file:test.db?_foreign_keys=1",
|
||||
expected: "file:test.db?_pragma=foreign_keys%281%29",
|
||||
},
|
||||
{
|
||||
name: "converts _fk to pragma",
|
||||
input: "file:test.db?_fk=1",
|
||||
expected: "file:test.db?_pragma=foreign_keys%281%29",
|
||||
},
|
||||
{
|
||||
name: "converts _synchronous to pragma",
|
||||
input: "file:test.db?_synchronous=NORMAL",
|
||||
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
|
||||
},
|
||||
{
|
||||
name: "converts _sync to pragma",
|
||||
input: "file:test.db?_sync=NORMAL",
|
||||
expected: "file:test.db?_pragma=synchronous%28NORMAL%29",
|
||||
},
|
||||
{
|
||||
name: "converts _auto_vacuum to pragma",
|
||||
input: "file:test.db?_auto_vacuum=FULL",
|
||||
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
|
||||
},
|
||||
{
|
||||
name: "converts _vacuum to pragma",
|
||||
input: "file:test.db?_vacuum=FULL",
|
||||
expected: "file:test.db?_pragma=auto_vacuum%28FULL%29",
|
||||
},
|
||||
{
|
||||
name: "converts _case_sensitive_like to pragma",
|
||||
input: "file:test.db?_case_sensitive_like=1",
|
||||
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
|
||||
},
|
||||
{
|
||||
name: "converts _cslike to pragma",
|
||||
input: "file:test.db?_cslike=1",
|
||||
expected: "file:test.db?_pragma=case_sensitive_like%281%29",
|
||||
},
|
||||
{
|
||||
name: "converts _locking_mode to pragma",
|
||||
input: "file:test.db?_locking_mode=EXCLUSIVE",
|
||||
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
|
||||
},
|
||||
{
|
||||
name: "converts _locking to pragma",
|
||||
input: "file:test.db?_locking=EXCLUSIVE",
|
||||
expected: "file:test.db?_pragma=locking_mode%28EXCLUSIVE%29",
|
||||
},
|
||||
{
|
||||
name: "converts _secure_delete to pragma",
|
||||
input: "file:test.db?_secure_delete=1",
|
||||
expected: "file:test.db?_pragma=secure_delete%281%29",
|
||||
},
|
||||
{
|
||||
name: "preserves unrecognized parameters",
|
||||
input: "file:test.db?mode=rw&cache=shared",
|
||||
expected: "file:test.db?cache=shared&mode=rw",
|
||||
},
|
||||
{
|
||||
name: "handles multiple parameters",
|
||||
input: "file:test.db?_fk=1&mode=rw&_timeout=5000",
|
||||
expected: "file:test.db?_pragma=foreign_keys%281%29&_pragma=busy_timeout%285000%29&mode=rw",
|
||||
},
|
||||
}
|
||||
|
||||
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: "basic file database",
|
||||
input: "file:test.db",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "in-memory database",
|
||||
input: "file::memory:",
|
||||
isMemoryDB: true,
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||
},
|
||||
{
|
||||
name: "read-only database with mode=ro",
|
||||
input: "file:test.db?mode=ro",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||
},
|
||||
{
|
||||
name: "immutable database",
|
||||
input: "file:test.db?immutable=1",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||
},
|
||||
{
|
||||
name: "database with existing _txlock",
|
||||
input: "file:test.db?_txlock=deferred",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=deferred",
|
||||
},
|
||||
{
|
||||
name: "database with existing busy_timeout pragma",
|
||||
input: "file:test.db?_pragma=busy_timeout%285000%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%285000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "database with existing journal_mode pragma",
|
||||
input: "file:test.db?_pragma=journal_mode%28DELETE%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "database with forbidden foreign_keys pragma",
|
||||
input: "file:test.db?_pragma=foreign_keys%280%29",
|
||||
isMemoryDB: false,
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "database with multiple existing pragmas",
|
||||
input: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%283000%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28TRUNCATE%29&_pragma=synchronous%28NORMAL%29&_txlock=immediate",
|
||||
},
|
||||
{
|
||||
name: "in-memory database with cache already set",
|
||||
input: "file::memory:?cache=private",
|
||||
isMemoryDB: true,
|
||||
expected: "file::memory:?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28MEMORY%29&_txlock=immediate&cache=shared",
|
||||
},
|
||||
{
|
||||
name: "database with mode=rw (not read-only)",
|
||||
input: "file:test.db?mode=rw",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&mode=rw",
|
||||
},
|
||||
{
|
||||
name: "database with immutable=0 (not immutable)",
|
||||
input: "file:test.db?immutable=0",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_txlock=immediate&immutable=0",
|
||||
},
|
||||
{
|
||||
name: "database with mixed case mode=RO",
|
||||
input: "file:test.db?mode=RO",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&mode=ro",
|
||||
},
|
||||
{
|
||||
name: "database with mixed case immutable=1",
|
||||
input: "file:test.db?immutable=1",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28DELETE%29&_txlock=immediate&immutable=1",
|
||||
},
|
||||
{
|
||||
name: "complex database configuration",
|
||||
input: "file:test.db?cache=shared&mode=rwc&_txlock=immediate&_pragma=synchronous%28FULL%29",
|
||||
isMemoryDB: false,
|
||||
expected: "file:test.db?_pragma=busy_timeout%282500%29&_pragma=foreign_keys%281%29&_pragma=journal_mode%28WAL%29&_pragma=synchronous%28FULL%29&_txlock=immediate&cache=shared&mode=rwc",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
resultURL, err := url.Parse(tt.input)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = addSqliteDefaultParameters(resultURL, tt.isMemoryDB)
|
||||
|
||||
if tt.expectError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
expectedURL, err := url.Parse(tt.expected)
|
||||
require.NoError(t, err)
|
||||
|
||||
compareQueryStrings(t, expectedURL, resultURL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func compareQueryStrings(t *testing.T, expectedURL *url.URL, resultURL *url.URL) {
|
||||
t.Helper()
|
||||
|
||||
// Compare scheme and path components
|
||||
assert.Equal(t, expectedURL.Scheme, resultURL.Scheme)
|
||||
assert.Equal(t, expectedURL.Path, resultURL.Path)
|
||||
|
||||
// Compare query parameters regardless of order
|
||||
expectedQuery := expectedURL.Query()
|
||||
resultQuery := resultURL.Query()
|
||||
|
||||
assert.Len(t, expectedQuery, len(resultQuery))
|
||||
|
||||
for key, expectedValues := range expectedQuery {
|
||||
resultValues, ok := resultQuery[key]
|
||||
_ = assert.True(t, ok) &&
|
||||
assert.ElementsMatch(t, expectedValues, resultValues)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,9 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -14,7 +17,13 @@ import (
|
||||
func init() {
|
||||
registerTestControllers = []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)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
const (
|
||||
privateKeyFilePem = "jwt_private_key.pem"
|
||||
)
|
||||
|
||||
func migrateKey() {
|
||||
err := migrateKeyInternal(common.EnvConfig.KeysPath)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to perform migration of keys: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func migrateKeyInternal(basePath string) error {
|
||||
// First, check if there's already a JWK stored
|
||||
jwkPath := filepath.Join(basePath, service.PrivateKeyFile)
|
||||
ok, err := utils.FileExists(jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (JWK) exists at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
if ok {
|
||||
// There's already a key as JWK, so we don't do anything else here
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if there's a PEM file
|
||||
pemPath := filepath.Join(basePath, privateKeyFilePem)
|
||||
ok, err = utils.FileExists(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if private key file (PEM) exists at path '%s': %w", pemPath, err)
|
||||
}
|
||||
if !ok {
|
||||
// No file to migrate, return
|
||||
return nil
|
||||
}
|
||||
|
||||
// Load and validate the key
|
||||
key, err := loadKeyPEM(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load private key file (PEM) at path '%s': %w", pemPath, err)
|
||||
}
|
||||
err = service.ValidateKey(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("key object is invalid: %w", err)
|
||||
}
|
||||
|
||||
// Save the key as JWK
|
||||
err = service.SaveKeyJWK(key, jwkPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save private key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
// Finally, delete the PEM file
|
||||
err = os.Remove(pemPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to remove migrated key at path '%s': %w", pemPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadKeyPEM(path string) (jwk.Key, error) {
|
||||
// Load the key from disk and parse it
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read key data: %w", err)
|
||||
}
|
||||
|
||||
key, err := jwk.ParseKey(data, jwk.WithPEM(true))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse key: %w", err)
|
||||
}
|
||||
|
||||
// Populate the key ID using the "legacy" algorithm
|
||||
keyId, err := generateKeyID(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate key ID: %w", err)
|
||||
}
|
||||
err = key.Set(jwk.KeyIDKey, keyId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set key ID: %w", err)
|
||||
}
|
||||
|
||||
// Populate other required fields
|
||||
_ = key.Set(jwk.KeyUsageKey, service.KeyUsageSigning)
|
||||
service.EnsureAlgInKey(key)
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// generateKeyID generates a Key ID for the public key using the first 8 bytes of the SHA-256 hash of the public key's PKIX-serialized structure.
|
||||
// This is used for legacy keys, imported from PEM.
|
||||
func generateKeyID(key jwk.Key) (string, error) {
|
||||
// Export the public key and serialize it to PKIX (not in a PEM block)
|
||||
// This is for backwards-compatibility with the algorithm used before the switch to JWK
|
||||
pubKey, err := key.PublicKey()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get public key: %w", err)
|
||||
}
|
||||
var pubKeyRaw any
|
||||
err = jwk.Export(pubKey, &pubKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to export public key: %w", err)
|
||||
}
|
||||
pubASN1, err := x509.MarshalPKIXPublicKey(pubKeyRaw)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal public key: %w", err)
|
||||
}
|
||||
|
||||
// Compute SHA-256 hash of the public key
|
||||
hash := sha256.New()
|
||||
hash.Write(pubASN1)
|
||||
hashed := hash.Sum(nil)
|
||||
|
||||
// Truncate the hash to the first 8 bytes for a shorter Key ID
|
||||
shortHash := hashed[:8]
|
||||
|
||||
// Return Base64 encoded truncated hash as Key ID
|
||||
return base64.RawURLEncoding.EncodeToString(shortHash), nil
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func TestMigrateKey(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("no keys exist", func(t *testing.T) {
|
||||
// Test when no keys exist
|
||||
err := migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("jwk already exists", func(t *testing.T) {
|
||||
// Create a JWK file
|
||||
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
|
||||
key, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
err = service.SaveKeyJWK(key, jwkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run migration - should do nothing
|
||||
err = migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check the file still exists
|
||||
exists, err := utils.FileExists(jwkPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Delete for next test
|
||||
err = os.Remove(jwkPath)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("migrate pem to jwk", func(t *testing.T) {
|
||||
// Create a PEM file
|
||||
pemPath := filepath.Join(tempDir, privateKeyFilePem)
|
||||
jwkPath := filepath.Join(tempDir, service.PrivateKeyFile)
|
||||
|
||||
// Generate RSA key and save as PEM
|
||||
createRSAPrivateKeyPEM(t, pemPath)
|
||||
|
||||
// Run migration
|
||||
err := migrateKeyInternal(tempDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check PEM file is gone
|
||||
exists, err := utils.FileExists(pemPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists)
|
||||
|
||||
// Check JWK file exists
|
||||
exists, err = utils.FileExists(jwkPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists)
|
||||
|
||||
// Verify the JWK can be loaded
|
||||
data, err := os.ReadFile(jwkPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = jwk.ParseKey(data)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadKeyPEM(t *testing.T) {
|
||||
// Create a temporary directory for testing
|
||||
tempDir := t.TempDir()
|
||||
|
||||
t.Run("successfully load PEM key", func(t *testing.T) {
|
||||
pemPath := filepath.Join(tempDir, "test_key.pem")
|
||||
|
||||
// Generate RSA key and save as PEM
|
||||
createRSAPrivateKeyPEM(t, pemPath)
|
||||
|
||||
// Load the key
|
||||
key, err := loadKeyPEM(pemPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key properties
|
||||
assert.NotEmpty(t, key)
|
||||
|
||||
// Check key ID is set
|
||||
var keyID string
|
||||
err = key.Get(jwk.KeyIDKey, &keyID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
// Check algorithm is set
|
||||
var alg jwa.SignatureAlgorithm
|
||||
err = key.Get(jwk.AlgorithmKey, &alg)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, alg)
|
||||
|
||||
// Check key usage is set
|
||||
var keyUsage string
|
||||
err = key.Get(jwk.KeyUsageKey, &keyUsage)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, service.KeyUsageSigning, keyUsage)
|
||||
})
|
||||
|
||||
t.Run("file not found", func(t *testing.T) {
|
||||
key, err := loadKeyPEM(filepath.Join(tempDir, "nonexistent.pem"))
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
})
|
||||
|
||||
t.Run("invalid file content", func(t *testing.T) {
|
||||
invalidPath := filepath.Join(tempDir, "invalid.pem")
|
||||
err := os.WriteFile(invalidPath, []byte("not a valid PEM"), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := loadKeyPEM(invalidPath)
|
||||
require.Error(t, err)
|
||||
assert.Nil(t, key)
|
||||
})
|
||||
}
|
||||
|
||||
func TestGenerateKeyID(t *testing.T) {
|
||||
key, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID, err := generateKeyID(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Key ID should be non-empty
|
||||
assert.NotEmpty(t, keyID)
|
||||
|
||||
// Generate another key ID to prove it depends on the key
|
||||
key2, err := createTestRSAKey()
|
||||
require.NoError(t, err)
|
||||
|
||||
keyID2, err := generateKeyID(key2)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The two key IDs should be different
|
||||
assert.NotEqual(t, keyID, keyID2)
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func createTestRSAKey() (jwk.Key, error) {
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
key, err := jwk.Import(privateKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// createRSAPrivateKeyPEM generates an RSA private key and returns its PEM-encoded form
|
||||
func createRSAPrivateKeyPEM(t *testing.T, pemPath string) ([]byte, *rsa.PrivateKey) {
|
||||
// Generate RSA key
|
||||
privKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Encode to PEM format
|
||||
pemData := pem.EncodeToMemory(&pem.Block{
|
||||
Type: "RSA PRIVATE KEY",
|
||||
Bytes: x509.MarshalPKCS1PrivateKey(privKey),
|
||||
})
|
||||
|
||||
err = os.WriteFile(pemPath, pemData, 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
return pemData, privKey
|
||||
}
|
||||
210
backend/internal/bootstrap/observability_boostrap.go
Normal file
210
backend/internal/bootstrap/observability_boostrap.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lmittmann/tint"
|
||||
"github.com/mattn/go-isatty"
|
||||
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||
"go.opentelemetry.io/contrib/exporters/autoexport"
|
||||
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
|
||||
"go.opentelemetry.io/otel"
|
||||
globallog "go.opentelemetry.io/otel/log/global"
|
||||
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||
"go.opentelemetry.io/otel/sdk/metric"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.30.0"
|
||||
tracenoop "go.opentelemetry.io/otel/trace/noop"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
func defaultResource() (*resource.Resource, error) {
|
||||
return resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewSchemaless(
|
||||
semconv.ServiceName(common.Name),
|
||||
semconv.ServiceVersion(common.Version),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) {
|
||||
resource, err := defaultResource()
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err)
|
||||
}
|
||||
|
||||
shutdownFns = make([]utils.Service, 0, 2)
|
||||
|
||||
httpClient = &http.Client{}
|
||||
defaultTransport, ok := http.DefaultTransport.(*http.Transport)
|
||||
if !ok {
|
||||
// Indicates a development-time error
|
||||
panic("Default transport is not of type *http.Transport")
|
||||
}
|
||||
httpClient.Transport = defaultTransport.Clone()
|
||||
|
||||
// Logging
|
||||
err = initOtelLogging(ctx, resource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
// Tracing
|
||||
tracingShutdownFn, err := initOtelTracing(ctx, traces, resource, httpClient)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if tracingShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, tracingShutdownFn)
|
||||
}
|
||||
|
||||
// Metrics
|
||||
metricsShutdownFn, err := initOtelMetrics(ctx, metrics, resource)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
} else if metricsShutdownFn != nil {
|
||||
shutdownFns = append(shutdownFns, metricsShutdownFn)
|
||||
}
|
||||
|
||||
return shutdownFns, httpClient, nil
|
||||
}
|
||||
|
||||
func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
|
||||
// If the env var OTEL_LOGS_EXPORTER is empty, we set it to "none", for autoexport to work
|
||||
if os.Getenv("OTEL_LOGS_EXPORTER") == "" {
|
||||
os.Setenv("OTEL_LOGS_EXPORTER", "none")
|
||||
}
|
||||
exp, err := autoexport.NewLogExporter(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
|
||||
}
|
||||
|
||||
level := slog.LevelDebug
|
||||
if common.EnvConfig.AppEnv == "production" {
|
||||
level = slog.LevelInfo
|
||||
}
|
||||
|
||||
// Create the handler
|
||||
var handler slog.Handler
|
||||
switch {
|
||||
case common.EnvConfig.LogJSON:
|
||||
// Log as JSON if configured
|
||||
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
case isatty.IsTerminal(os.Stdout.Fd()):
|
||||
// Enable colors if we have a TTY
|
||||
handler = tint.NewHandler(os.Stdout, &tint.Options{
|
||||
TimeFormat: time.StampMilli,
|
||||
Level: level,
|
||||
})
|
||||
default:
|
||||
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: level,
|
||||
})
|
||||
}
|
||||
|
||||
// Create the logger provider
|
||||
provider := sdklog.NewLoggerProvider(
|
||||
sdklog.WithProcessor(
|
||||
sdklog.NewBatchProcessor(exp),
|
||||
),
|
||||
sdklog.WithResource(resource),
|
||||
)
|
||||
|
||||
// Set the logger provider globally
|
||||
globallog.SetLoggerProvider(provider)
|
||||
|
||||
// Wrap the handler in a "fanout" one
|
||||
handler = utils.LogFanoutHandler{
|
||||
handler,
|
||||
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
|
||||
}
|
||||
|
||||
// Set the default slog to send logs to OTel and add the app name
|
||||
log := slog.New(handler).
|
||||
With(slog.String("app", common.Name)).
|
||||
With(slog.String("version", common.Version))
|
||||
slog.SetDefault(log)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) {
|
||||
if !traces {
|
||||
otel.SetTracerProvider(tracenoop.NewTracerProvider())
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
tr, err := autoexport.NewSpanExporter(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry span exporter: %w", err)
|
||||
}
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithResource(resource),
|
||||
sdktrace.WithBatcher(tr),
|
||||
)
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(
|
||||
propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
),
|
||||
)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
tpCtx, tpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer tpCancel()
|
||||
shutdownErr := tp.Shutdown(tpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down traces exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Add tracing to the HTTP client
|
||||
httpClient.Transport = otelhttp.NewTransport(httpClient.Transport)
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
|
||||
func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) {
|
||||
if !metrics {
|
||||
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
mr, err := autoexport.NewMetricReader(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to initialize OpenTelemetry metric reader: %w", err)
|
||||
}
|
||||
|
||||
mp := metric.NewMeterProvider(
|
||||
metric.WithResource(resource),
|
||||
metric.WithReader(mr),
|
||||
)
|
||||
otel.SetMeterProvider(mp)
|
||||
|
||||
shutdownFn = func(shutdownCtx context.Context) error { //nolint:contextcheck
|
||||
mpCtx, mpCancel := context.WithTimeout(shutdownCtx, 10*time.Second)
|
||||
defer mpCancel()
|
||||
shutdownErr := mp.Shutdown(mpCtx)
|
||||
if shutdownErr != nil {
|
||||
return fmt.Errorf("failed to gracefully shut down metrics exporter: %w", shutdownErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return shutdownFn, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -2,17 +2,23 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
sloggin "github.com/samber/slog-gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/frontend"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/controller"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
@@ -26,7 +32,8 @@ var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *
|
||||
func initRouter(db *gorm.DB, svc *services) utils.Service {
|
||||
runner, err := initRouterInternal(db, svc)
|
||||
if err != nil {
|
||||
log.Fatalf("failed to init router: %v", err)
|
||||
slog.Error("Failed to init router", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
@@ -42,11 +49,37 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
|
||||
r := gin.Default()
|
||||
r.Use(gin.Logger())
|
||||
// do not log these URLs
|
||||
loggerSkipPathsPrefix := []string{
|
||||
"GET /application-configuration/logo",
|
||||
"GET /application-configuration/background-image",
|
||||
"GET /application-configuration/favicon",
|
||||
"GET /_app",
|
||||
"GET /fonts",
|
||||
"GET /healthz",
|
||||
"HEAD /healthz",
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(sloggin.NewWithConfig(slog.Default(), sloggin.Config{
|
||||
Filters: []sloggin.Filter{
|
||||
func(c *gin.Context) bool {
|
||||
for _, prefix := range loggerSkipPathsPrefix {
|
||||
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
},
|
||||
},
|
||||
}))
|
||||
|
||||
if !common.EnvConfig.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
if common.EnvConfig.TracingEnabled {
|
||||
r.Use(otelgin.Middleware("pocket-id-backend"))
|
||||
r.Use(otelgin.Middleware(common.Name))
|
||||
}
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
|
||||
@@ -55,6 +88,13 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
|
||||
err := frontend.RegisterFrontend(r)
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||
}
|
||||
|
||||
// Initialize middleware for specific routes
|
||||
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
@@ -87,21 +127,39 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
|
||||
// Set up the server
|
||||
srv := &http.Server{
|
||||
Addr: net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port),
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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
|
||||
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
|
||||
go func() {
|
||||
@@ -110,7 +168,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
// Next call blocks until the server is shut down
|
||||
srvErr := srv.Serve(listener)
|
||||
if srvErr != http.ErrServerClosed {
|
||||
log.Fatalf("Error starting app server: %v", srvErr)
|
||||
slog.Error("Error starting app server", "error", srvErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -118,7 +177,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
err = systemd.SdNotifyReady()
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
log.Printf("[WARN] Unable to notify systemd that the service is ready: %v", err)
|
||||
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||
}
|
||||
|
||||
// Block until the context is canceled
|
||||
@@ -131,7 +190,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
shutdownCancel()
|
||||
if shutdownErr != nil {
|
||||
// Log the error only (could be context canceled)
|
||||
log.Printf("[WARN] App server shutdown error: %v", shutdownErr)
|
||||
slog.Warn("App server shutdown error", "error", shutdownErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -3,13 +3,14 @@ package bootstrap
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
)
|
||||
|
||||
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error {
|
||||
func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error {
|
||||
err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err)
|
||||
@@ -30,6 +31,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, sche
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err)
|
||||
}
|
||||
err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register analytics job in scheduler: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -26,27 +26,41 @@ type services struct {
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
// The context should be used by services only for initialization, and not for running
|
||||
func initServices(initCtx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
||||
func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (svc *services, err error) {
|
||||
svc = &services{}
|
||||
|
||||
svc.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)
|
||||
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.auditLogService = service.NewAuditLogService(db, svc.appConfigService, svc.emailService, svc.geoLiteService)
|
||||
svc.jwtService = service.NewJwtService(svc.appConfigService)
|
||||
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)
|
||||
svc.jwtService, err = service.NewJwtService(db, svc.appConfigService)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create JWT service: %w", err)
|
||||
}
|
||||
|
||||
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.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService)
|
||||
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
|
||||
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
|
||||
svc.webauthnService = service.NewWebAuthnService(db, svc.jwtService, svc.auditLogService, svc.appConfigService)
|
||||
|
||||
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, "")
|
||||
})
|
||||
}
|
||||
}
|
||||
85
backend/internal/cmds/one_time_access_token.go
Normal file
85
backend/internal/cmds/one_time_access_token.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"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/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
var oneTimeAccessTokenCmd = &cobra.Command{
|
||||
Use: "one-time-access-token [username or email]",
|
||||
Short: "Generates a one-time access token for the given user",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Get the username or email of the user
|
||||
userArg := args[0]
|
||||
|
||||
// Connect to the database
|
||||
db, err := bootstrap.NewDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create the access token
|
||||
var oneTimeAccessToken *model.OneTimeAccessToken
|
||||
err = db.Transaction(func(tx *gorm.DB) error {
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(oneTimeAccessTokenCmd)
|
||||
}
|
||||
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,8 +1,13 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"log"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/caarlos0/env/v11"
|
||||
_ "github.com/joho/godotenv/autoload"
|
||||
@@ -18,73 +23,184 @@ const (
|
||||
)
|
||||
|
||||
const (
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||
DbProviderSqlite DbProvider = "sqlite"
|
||||
DbProviderPostgres DbProvider = "postgres"
|
||||
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
|
||||
defaultSqliteConnString string = "data/pocket-id.db"
|
||||
)
|
||||
|
||||
type EnvConfigSchema struct {
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
AppURL string `env:"PUBLIC_APP_URL"`
|
||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING"`
|
||||
SqliteDBPath string `env:"SQLITE_DB_PATH"` // Deprecated: use "DB_CONNECTION_STRING" instead
|
||||
PostgresConnectionString string `env:"POSTGRES_CONNECTION_STRING"` // Deprecated: use "DB_CONNECTION_STRING" instead
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
KeysPath string `env:"KEYS_PATH"`
|
||||
Port string `env:"BACKEND_PORT"`
|
||||
Host string `env:"HOST"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
UiConfigDisabled bool `env:"PUBLIC_UI_CONFIG_DISABLED"`
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||
AppEnv string `env:"APP_ENV"`
|
||||
AppURL string `env:"APP_URL"`
|
||||
DbProvider DbProvider `env:"DB_PROVIDER"`
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
UploadPath string `env:"UPLOAD_PATH"`
|
||||
KeysPath string `env:"KEYS_PATH"`
|
||||
KeysStorage string `env:"KEYS_STORAGE"`
|
||||
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
|
||||
Port string `env:"PORT"`
|
||||
Host string `env:"HOST"`
|
||||
UnixSocket string `env:"UNIX_SOCKET"`
|
||||
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
TrustProxy bool `env:"TRUST_PROXY"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
}
|
||||
|
||||
var EnvConfig = &EnvConfigSchema{
|
||||
AppEnv: "production",
|
||||
DbProvider: "sqlite",
|
||||
DbConnectionString: "file:data/pocket-id.db?_journal_mode=WAL&_busy_timeout=2500&_txlock=immediate",
|
||||
SqliteDBPath: "",
|
||||
PostgresConnectionString: "",
|
||||
UploadPath: "data/uploads",
|
||||
KeysPath: "data/keys",
|
||||
AppURL: "http://localhost",
|
||||
Port: "8080",
|
||||
Host: "0.0.0.0",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
UiConfigDisabled: false,
|
||||
MetricsEnabled: false,
|
||||
TracingEnabled: false,
|
||||
}
|
||||
var EnvConfig = defaultConfig()
|
||||
|
||||
func init() {
|
||||
if err := env.ParseWithOptions(EnvConfig, env.Options{}); err != nil {
|
||||
log.Fatal(err)
|
||||
err := parseEnvConfig()
|
||||
if err != nil {
|
||||
slog.Error("Configuration error", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() EnvConfigSchema {
|
||||
return EnvConfigSchema{
|
||||
AppEnv: "production",
|
||||
DbProvider: "sqlite",
|
||||
DbConnectionString: "",
|
||||
UploadPath: "data/uploads",
|
||||
KeysPath: "data/keys",
|
||||
KeysStorage: "", // "database" or "file"
|
||||
EncryptionKey: nil,
|
||||
AppURL: "http://localhost:1411",
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
UnixSocket: "",
|
||||
UnixSocketMode: "",
|
||||
MaxMindLicenseKey: "",
|
||||
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
|
||||
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
|
||||
LocalIPv6Ranges: "",
|
||||
UiConfigDisabled: false,
|
||||
MetricsEnabled: false,
|
||||
TracingEnabled: false,
|
||||
TrustProxy: false,
|
||||
AnalyticsDisabled: false,
|
||||
AllowDowngrade: false,
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
switch EnvConfig.DbProvider {
|
||||
case DbProviderSqlite:
|
||||
if EnvConfig.DbConnectionString == "" {
|
||||
log.Fatal("Missing required env var 'DB_CONNECTION_STRING' for SQLite database")
|
||||
EnvConfig.DbConnectionString = defaultSqliteConnString
|
||||
}
|
||||
case DbProviderPostgres:
|
||||
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:
|
||||
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)
|
||||
if err != nil {
|
||||
log.Fatal("PUBLIC_APP_URL is not a valid URL")
|
||||
return errors.New("APP_URL is not a valid URL")
|
||||
}
|
||||
if parsedAppUrl.Path != "" {
|
||||
log.Fatal("PUBLIC_APP_URL must not contain a path")
|
||||
return errors.New("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
|
||||
}
|
||||
|
||||
305
backend/internal/common/env_config_test.go
Normal file
305
backend/internal/common/env_config_test.go
Normal file
@@ -0,0 +1,305 @@
|
||||
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 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) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcClientAssertionInvalidError struct{}
|
||||
|
||||
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
|
||||
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcInvalidAuthorizationCodeError struct{}
|
||||
|
||||
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcMissingCallbackURLError struct{}
|
||||
|
||||
func (e *OidcMissingCallbackURLError) Error() string {
|
||||
return "unable to detect callback url, it might be necessary for an admin to fix this"
|
||||
}
|
||||
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
|
||||
|
||||
type OidcInvalidCallbackURLError struct{}
|
||||
|
||||
func (e *OidcInvalidCallbackURLError) Error() string {
|
||||
@@ -156,13 +168,6 @@ func (e *DuplicateClaimError) Error() string {
|
||||
}
|
||||
func (e *DuplicateClaimError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type AccountEditNotAllowedError struct{}
|
||||
|
||||
func (e *AccountEditNotAllowedError) Error() string {
|
||||
return "You are not allowed to edit your account"
|
||||
}
|
||||
func (e *AccountEditNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidCodeVerifierError struct{}
|
||||
|
||||
func (e *OidcInvalidCodeVerifierError) Error() string {
|
||||
@@ -344,3 +349,32 @@ func (e *OidcAuthorizationPendingError) Error() string {
|
||||
func (e *OidcAuthorizationPendingError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
type ReauthenticationRequiredError struct{}
|
||||
|
||||
func (e *ReauthenticationRequiredError) Error() string {
|
||||
return "reauthentication required"
|
||||
}
|
||||
func (e *ReauthenticationRequiredError) HttpStatusCode() int {
|
||||
return http.StatusUnauthorized
|
||||
}
|
||||
|
||||
type OpenSignupDisabledError struct{}
|
||||
|
||||
func (e *OpenSignupDisabledError) Error() string {
|
||||
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
|
||||
|
||||
// Name is the name of the application
|
||||
const Name = "pocket-id"
|
||||
|
||||
// Version contains the Pocket ID version.
|
||||
//
|
||||
// It can be set at build time using -ldflags.
|
||||
|
||||
@@ -38,10 +38,10 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
||||
// @Summary List API keys
|
||||
// @Description Get a paginated list of API keys belonging to the current user
|
||||
// @Tags API Keys
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.ApiKeyDto]
|
||||
// @Router /api/api-keys [get]
|
||||
func (c *ApiKeyController) listApiKeysHandler(ctx *gin.Context) {
|
||||
@@ -82,7 +82,7 @@ func (c *ApiKeyController) createApiKeyHandler(ctx *gin.Context) {
|
||||
userID := ctx.GetString("userID")
|
||||
|
||||
var input dto.ApiKeyCreateDto
|
||||
if err := ctx.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(ctx, &input); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package controller
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -57,7 +58,6 @@ type AppConfigController struct {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.PublicAppConfigVariableDto
|
||||
// @Failure 500 {object} object "{"error": "error message"}"
|
||||
// @Router /application-configuration [get]
|
||||
func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
configuration := acc.appConfigService.ListAppConfig(false)
|
||||
@@ -68,6 +68,13 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Manually add uiConfigDisabled which isn't in the database but defined with an environment variable
|
||||
configVariablesDto = append(configVariablesDto, dto.PublicAppConfigVariableDto{
|
||||
Key: "uiConfigDisabled",
|
||||
Value: strconv.FormatBool(common.EnvConfig.UiConfigDisabled),
|
||||
Type: "boolean",
|
||||
})
|
||||
|
||||
c.JSON(http.StatusOK, configVariablesDto)
|
||||
}
|
||||
|
||||
@@ -78,7 +85,6 @@ func (acc *AppConfigController) listAppConfigHandler(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Security BearerAuth
|
||||
// @Router /application-configuration/all [get]
|
||||
func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
configuration := acc.appConfigService.ListAppConfig(true)
|
||||
@@ -100,11 +106,10 @@ func (acc *AppConfigController) listAllAppConfigHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param body body dto.AppConfigUpdateDto true "Application Configuration"
|
||||
// @Success 200 {array} dto.AppConfigVariableDto
|
||||
// @Security BearerAuth
|
||||
// @Router /api/application-configuration [put]
|
||||
func (acc *AppConfigController) updateAppConfigHandler(c *gin.Context) {
|
||||
var input dto.AppConfigUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -157,7 +162,6 @@ func (acc *AppConfigController) getLogoHandler(c *gin.Context) {
|
||||
// @Tags Application Configuration
|
||||
// @Produce image/x-icon
|
||||
// @Success 200 {file} binary "Favicon image"
|
||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
||||
// @Router /api/application-configuration/favicon [get]
|
||||
func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||
acc.getImage(c, "favicon", "ico")
|
||||
@@ -170,7 +174,6 @@ func (acc *AppConfigController) getFaviconHandler(c *gin.Context) {
|
||||
// @Produce image/png
|
||||
// @Produce image/jpeg
|
||||
// @Success 200 {file} binary "Background image"
|
||||
// @Failure 404 {object} object "{"error": "File not found"}"
|
||||
// @Router /api/application-configuration/background-image [get]
|
||||
func (acc *AppConfigController) getBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
||||
@@ -185,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 file formData file true "Logo image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/application-configuration/logo [put]
|
||||
func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
dbConfig := acc.appConfigService.GetDbConfig()
|
||||
@@ -211,7 +213,6 @@ func (acc *AppConfigController) updateLogoHandler(c *gin.Context) {
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Favicon file (.ico)"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/application-configuration/favicon [put]
|
||||
func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
@@ -235,7 +236,6 @@ func (acc *AppConfigController) updateFaviconHandler(c *gin.Context) {
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Background image file"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/application-configuration/background-image [put]
|
||||
func (acc *AppConfigController) updateBackgroundImageHandler(c *gin.Context) {
|
||||
imageType := acc.appConfigService.GetDbConfig().BackgroundImageType.Value
|
||||
@@ -248,6 +248,8 @@ func (acc *AppConfigController) getImage(c *gin.Context, name string, imageType
|
||||
mimeType := utils.GetImageMimeType(imageType)
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
|
||||
utils.SetCacheControlHeader(c, 15*time.Minute, 24*time.Hour)
|
||||
c.File(imagePath)
|
||||
}
|
||||
|
||||
@@ -273,7 +275,6 @@ func (acc *AppConfigController) updateImage(c *gin.Context, imageName string, ol
|
||||
// @Description Manually trigger LDAP synchronization
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/application-configuration/sync-ldap [post]
|
||||
func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
err := acc.ldapService.SyncAll(c.Request.Context())
|
||||
@@ -290,7 +291,6 @@ func (acc *AppConfigController) syncLdapHandler(c *gin.Context) {
|
||||
// @Description Send a test email to verify email configuration
|
||||
// @Tags Application Configuration
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/application-configuration/test-email [post]
|
||||
func (acc *AppConfigController) testEmailHandler(c *gin.Context) {
|
||||
userID := c.GetString("userID")
|
||||
|
||||
@@ -34,10 +34,10 @@ type AuditLogController struct {
|
||||
// @Summary List audit logs
|
||||
// @Description Get a paginated list of audit logs for the current user
|
||||
// @Tags Audit Logs
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /api/audit-logs [get]
|
||||
func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
@@ -82,13 +82,14 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
// @Summary List all audit logs
|
||||
// @Description Get a paginated list of all audit logs (admin only)
|
||||
// @Tags Audit Logs
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Param user_id query string false "Filter by user ID"
|
||||
// @Param event query string false "Filter by event type"
|
||||
// @Param client_name query string false "Filter by client name"
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param filters[userId] query string false "Filter by user ID"
|
||||
// @Param filters[event] query string false "Filter by event type"
|
||||
// @Param filters[clientName] query string false "Filter by client name"
|
||||
// @Param filters[location] query string false "Filter by location type (external or internal)"
|
||||
// @Success 200 {object} dto.Paginated[dto.AuditLogDto]
|
||||
// @Router /api/audit-logs/all [get]
|
||||
func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||
|
||||
@@ -35,10 +35,6 @@ type CustomClaimController struct {
|
||||
// @Tags Custom Claims
|
||||
// @Produce json
|
||||
// @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]
|
||||
func (ccc *CustomClaimController) getSuggestionsHandler(c *gin.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) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -93,12 +89,11 @@ func (ccc *CustomClaimController) UpdateCustomClaimsForUserHandler(c *gin.Contex
|
||||
// @Param userGroupId path string true "User Group ID"
|
||||
// @Param claims body []dto.CustomClaimCreateDto true "List of custom claims to set for the user group"
|
||||
// @Success 200 {array} dto.CustomClaimDto "Updated custom claims"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/custom-claims/user-group/{userGroupId} [put]
|
||||
func (ccc *CustomClaimController) UpdateCustomClaimsForUserGroupHandler(c *gin.Context) {
|
||||
var input []dto.CustomClaimCreateDto
|
||||
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ func NewTestController(group *gin.RouterGroup, testService *service.TestService)
|
||||
testController := &TestController{TestService: testService}
|
||||
|
||||
group.POST("/test/reset", testController.resetAndSeedHandler)
|
||||
group.POST("/test/refreshtoken", testController.signRefreshToken)
|
||||
|
||||
group.GET("/externalidp/jwks.json", testController.externalIdPJWKS)
|
||||
group.POST("/externalidp/sign", testController.externalIdPSignToken)
|
||||
}
|
||||
|
||||
type TestController struct {
|
||||
@@ -21,19 +25,31 @@ type TestController struct {
|
||||
}
|
||||
|
||||
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 {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(); err != nil {
|
||||
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SeedDatabase(); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if !skipSeed {
|
||||
if err := tc.TestService.SeedDatabase(baseURL); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetAppConfig(c.Request.Context()); err != nil {
|
||||
@@ -41,17 +57,71 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
if !skipLdap {
|
||||
if err := tc.TestService.SetLdapTestConfig(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
if err := tc.TestService.SyncLdap(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
tc.TestService.SetJWTKeys()
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (tc *TestController) externalIdPJWKS(c *gin.Context) {
|
||||
jwks, err := tc.TestService.GetExternalIdPJWKS()
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, jwks)
|
||||
}
|
||||
|
||||
func (tc *TestController) externalIdPSignToken(c *gin.Context) {
|
||||
var input struct {
|
||||
Aud string `json:"aud"`
|
||||
Iss string `json:"iss"`
|
||||
Sub string `json:"sub"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignExternalIdPToken(input.Iss, input.Sub, input.Aud)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
}
|
||||
|
||||
func (tc *TestController) signRefreshToken(c *gin.Context) {
|
||||
var input struct {
|
||||
UserID string `json:"user"`
|
||||
ClientID string `json:"client"`
|
||||
RefreshToken string `json:"rt"`
|
||||
}
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := tc.TestService.SignRefreshToken(input.UserID, input.ClientID, input.RefreshToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Writer.WriteString(token)
|
||||
}
|
||||
|
||||
@@ -2,19 +2,20 @@ package controller
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
)
|
||||
|
||||
// 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.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||
|
||||
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||
|
||||
group.POST("/oidc/device/authorize", oc.deviceAuthorizationHandler)
|
||||
group.POST("/oidc/device/verify", authMiddleware.WithAdminNotRequired().Add(), oc.verifyDeviceCodeHandler)
|
||||
group.GET("/oidc/device/info", authMiddleware.WithAdminNotRequired().Add(), oc.getDeviceCodeInfoHandler)
|
||||
|
||||
group.GET("/oidc/users/me/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 {
|
||||
@@ -66,7 +77,6 @@ type OidcController struct {
|
||||
// @Produce json
|
||||
// @Param request body dto.AuthorizeOidcClientRequestDto true "Authorization request parameters"
|
||||
// @Success 200 {object} dto.AuthorizeOidcClientResponseDto "Authorization code and callback URL"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/authorize [post]
|
||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
@@ -84,6 +94,7 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
response := dto.AuthorizeOidcClientResponseDto{
|
||||
Code: code,
|
||||
CallbackURL: callbackURL,
|
||||
Issuer: common.EnvConfig.AppURL,
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
@@ -97,7 +108,6 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param request body dto.AuthorizationRequiredDto true "Authorization check parameters"
|
||||
// @Success 200 {object} object "{ \"authorizationRequired\": true/false }"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/authorization-required [post]
|
||||
func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Context) {
|
||||
var input dto.AuthorizationRequiredDto
|
||||
@@ -121,11 +131,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param client_id formData string false "Client ID (if not using Basic Auth)"
|
||||
// @Param client_secret formData string false "Client secret (if not using Basic Auth)"
|
||||
// @Param client_secret formData string false "Client secret (if not using Basic Auth or client assertions)"
|
||||
// @Param code formData string false "Authorization code (required for 'authorization_code' grant)"
|
||||
// @Param grant_type formData string true "Grant type ('authorization_code' or 'refresh_token')"
|
||||
// @Param code_verifier formData string false "PKCE code verifier (for authorization_code with PKCE)"
|
||||
// @Param refresh_token formData string false "Refresh token (required for 'refresh_token' grant)"
|
||||
// @Param client_assertion formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
|
||||
// @Param client_assertion_type formData string false "Client assertion type (for 'authorization_code' grant when using client assertions)"
|
||||
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
|
||||
// @Router /api/oidc/token [post]
|
||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
@@ -136,13 +148,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
// Validate that code is provided for authorization_code grant type
|
||||
if input.GrantType == "authorization_code" && input.Code == "" {
|
||||
if input.GrantType == service.GrantTypeAuthorizationCode && input.Code == "" {
|
||||
_ = c.Error(&common.OidcMissingAuthorizationCodeError{})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that refresh_token is provided for refresh_token grant type
|
||||
if input.GrantType == "refresh_token" && input.RefreshToken == "" {
|
||||
if input.GrantType == service.GrantTypeRefreshToken && input.RefreshToken == "" {
|
||||
_ = c.Error(&common.OidcMissingRefreshTokenError{})
|
||||
return
|
||||
}
|
||||
@@ -152,8 +164,7 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||
}
|
||||
|
||||
idToken, accessToken, refreshToken, expiresIn, err :=
|
||||
oc.oidcService.CreateTokens(c.Request.Context(), input)
|
||||
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
|
||||
@@ -171,23 +182,13 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response := dto.OidcTokenResponseDto{
|
||||
AccessToken: accessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: expiresIn,
|
||||
}
|
||||
|
||||
// Include ID token only for authorization_code grant
|
||||
if idToken != "" {
|
||||
response.IdToken = idToken
|
||||
}
|
||||
|
||||
// Include refresh token if generated
|
||||
if refreshToken != "" {
|
||||
response.RefreshToken = refreshToken
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, response)
|
||||
c.JSON(http.StatusOK, dto.OidcTokenResponseDto{
|
||||
AccessToken: tokens.AccessToken,
|
||||
TokenType: "Bearer",
|
||||
ExpiresIn: int(tokens.ExpiresIn.Seconds()),
|
||||
IdToken: tokens.IdToken, // May be empty
|
||||
RefreshToken: tokens.RefreshToken, // May be empty
|
||||
})
|
||||
}
|
||||
|
||||
// userInfoHandler godoc
|
||||
@@ -206,7 +207,7 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
token, err := oc.jwtService.VerifyOauthAccessToken(authToken)
|
||||
token, err := oc.jwtService.VerifyOAuthAccessToken(authToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -235,7 +236,6 @@ func (oc *OidcController) userInfoHandler(c *gin.Context) {
|
||||
// @Description End user session and handle OIDC logout
|
||||
// @Tags OIDC
|
||||
// @Accept application/x-www-form-urlencoded
|
||||
// @Produce html
|
||||
// @Param id_token_hint query string false "ID token"
|
||||
// @Param post_logout_redirect_uri query string false "URL to redirect to after logout"
|
||||
// @Param state query string false "State parameter to include in the redirect"
|
||||
@@ -262,7 +262,7 @@ func (oc *OidcController) EndSessionHandler(c *gin.Context) {
|
||||
callbackURL, err := oc.oidcService.ValidateEndSession(c.Request.Context(), input, c.GetString("userID"))
|
||||
if err != nil {
|
||||
// If the validation fails, the user has to confirm the logout manually and doesn't get redirected
|
||||
log.Printf("Error getting logout callback URL, the user has to confirm the logout manually: %v", err)
|
||||
slog.WarnContext(c.Request.Context(), "Error getting logout callback URL, the user has to confirm the logout manually", "error", err)
|
||||
c.Redirect(http.StatusFound, common.EnvConfig.AppURL+"/logout")
|
||||
return
|
||||
}
|
||||
@@ -315,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
|
||||
// supposed to interact with our IdP (since that needs to have a client_id
|
||||
// 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 {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -359,7 +371,6 @@ func (oc *OidcController) getClientMetaDataHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Client information"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id} [get]
|
||||
func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
clientId := c.Param("id")
|
||||
@@ -371,12 +382,12 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
|
||||
clientDto := dto.OidcClientWithAllowedUserGroupsDto{}
|
||||
err = dto.MapStruct(client, &clientDto)
|
||||
if err == nil {
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
_ = c.Error(err)
|
||||
c.JSON(http.StatusOK, clientDto)
|
||||
}
|
||||
|
||||
// listClientsHandler godoc
|
||||
@@ -384,12 +395,11 @@ func (oc *OidcController) getClientHandler(c *gin.Context) {
|
||||
// @Description Get a paginated list of OIDC clients with optional search and sorting
|
||||
// @Tags OIDC
|
||||
// @Param search query string false "Search term to filter clients by name"
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("name")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.OidcClientDto]
|
||||
// @Security BearerAuth
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]
|
||||
// @Router /api/oidc/clients [get]
|
||||
func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
searchTerm := c.Query("search")
|
||||
@@ -405,13 +415,23 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var clientsDto []dto.OidcClientDto
|
||||
if err := dto.MapStructList(clients, &clientsDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
// Map the user groups to DTOs
|
||||
var clientsDto = make([]dto.OidcClientWithAllowedGroupsCountDto, len(clients))
|
||||
for i, client := range clients {
|
||||
var clientDto dto.OidcClientWithAllowedGroupsCountDto
|
||||
if err := dto.MapStruct(client, &clientDto); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
clientDto.AllowedUserGroupsCount, err = oc.oidcService.GetAllowedGroupsCountOfClient(c, client.ID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
clientsDto[i] = clientDto
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientDto]{
|
||||
c.JSON(http.StatusOK, dto.Paginated[dto.OidcClientWithAllowedGroupsCountDto]{
|
||||
Data: clientsDto,
|
||||
Pagination: pagination,
|
||||
})
|
||||
@@ -425,7 +445,6 @@ func (oc *OidcController) listClientsHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Success 201 {object} dto.OidcClientWithAllowedUserGroupsDto "Created client"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients [post]
|
||||
func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
@@ -455,7 +474,6 @@ func (oc *OidcController) createClientHandler(c *gin.Context) {
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id} [delete]
|
||||
func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClient(c.Request.Context(), c.Param("id"))
|
||||
@@ -474,12 +492,11 @@ func (oc *OidcController) deleteClientHandler(c *gin.Context) {
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param client body dto.OidcClientCreateDto true "Client information"
|
||||
// @Param client body dto.OidcClientUpdateDto true "Client information"
|
||||
// @Success 200 {object} dto.OidcClientWithAllowedUserGroupsDto "Updated client"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id} [put]
|
||||
func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
var input dto.OidcClientCreateDto
|
||||
var input dto.OidcClientUpdateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -507,7 +524,6 @@ func (oc *OidcController) updateClientHandler(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 200 {object} object "{ \"secret\": \"string\" }"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/secret [post]
|
||||
func (oc *OidcController) createClientSecretHandler(c *gin.Context) {
|
||||
secret, err := oc.oidcService.CreateClientSecret(c.Request.Context(), c.Param("id"))
|
||||
@@ -536,6 +552,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
utils.SetCacheControlHeader(c, 15*time.Minute, 12*time.Hour)
|
||||
|
||||
c.Header("Content-Type", mimeType)
|
||||
c.File(imagePath)
|
||||
}
|
||||
@@ -546,9 +564,8 @@ func (oc *OidcController) getClientLogoHandler(c *gin.Context) {
|
||||
// @Tags OIDC
|
||||
// @Accept multipart/form-data
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG, max 2MB)"
|
||||
// @Param file formData file true "Logo image file (PNG, JPG, or SVG)"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/logo [post]
|
||||
func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
file, err := c.FormFile("file")
|
||||
@@ -572,7 +589,6 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
|
||||
// @Tags OIDC
|
||||
// @Param id path string true "Client ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/logo [delete]
|
||||
func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
err := oc.oidcService.DeleteClientLogo(c.Request.Context(), c.Param("id"))
|
||||
@@ -593,7 +609,6 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param groups body dto.OidcUpdateAllowedUserGroupsDto true "User group IDs"
|
||||
// @Success 200 {object} dto.OidcClientDto "Updated client"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/allowed-user-groups [put]
|
||||
func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||
var input dto.OidcUpdateAllowedUserGroupsDto
|
||||
@@ -638,6 +653,114 @@ func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||
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) {
|
||||
userCode := c.Query("code")
|
||||
if userCode == "" {
|
||||
@@ -673,3 +796,43 @@ func (oc *OidcController) getDeviceCodeInfoHandler(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, deviceCodeInfo)
|
||||
}
|
||||
|
||||
// getClientPreviewHandler godoc
|
||||
// @Summary Preview OIDC client data for user
|
||||
// @Description Get a preview of the OIDC data (ID token, access token, userinfo) that would be sent to the client for a specific user
|
||||
// @Tags OIDC
|
||||
// @Produce json
|
||||
// @Param id path string true "Client ID"
|
||||
// @Param userId path string true "User ID to preview data for"
|
||||
// @Param scopes query string false "Scopes to include in the preview (comma-separated)"
|
||||
// @Success 200 {object} dto.OidcClientPreviewDto "Preview data including ID token, access token, and userinfo payloads"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/oidc/clients/{id}/preview/{userId} [get]
|
||||
func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
clientID := c.Param("id")
|
||||
userID := c.Param("userId")
|
||||
scopes := c.Query("scopes")
|
||||
|
||||
if clientID == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "client ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if userID == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "user ID is required"})
|
||||
return
|
||||
}
|
||||
|
||||
if scopes == "" {
|
||||
_ = c.Error(&common.ValidationError{Message: "scopes are required"})
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, preview)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
@@ -15,6 +14,11 @@ import (
|
||||
"golang.org/x/time/rate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
defaultSignupTokenDuration = time.Hour
|
||||
)
|
||||
|
||||
// NewUserController creates a new controller for user management endpoints
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
@@ -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-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler)
|
||||
group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler)
|
||||
group.POST("/one-time-access-token/setup", uc.getSetupAccessTokenHandler)
|
||||
group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler)
|
||||
|
||||
group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler)
|
||||
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 {
|
||||
@@ -86,10 +96,10 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||
// @Description Get a paginated list of users with optional search and sorting
|
||||
// @Tags Users
|
||||
// @Param search query string false "Search term to filter users"
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("created_at")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("desc")
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserDto]
|
||||
// @Router /api/users [get]
|
||||
func (uc *UserController) listUsersHandler(c *gin.Context) {
|
||||
@@ -188,7 +198,7 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
// @Router /api/users [post]
|
||||
func (uc *UserController) createUserHandler(c *gin.Context) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -228,10 +238,6 @@ func (uc *UserController) updateUserHandler(c *gin.Context) {
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/users/me [put]
|
||||
func (uc *UserController) updateCurrentUserHandler(c *gin.Context) {
|
||||
if !uc.appConfigService.GetDbConfig().AllowOwnAccountEdit.IsTrue() {
|
||||
_ = c.Error(&common.AccountEditNotAllowedError{})
|
||||
return
|
||||
}
|
||||
uc.updateUser(c, true)
|
||||
}
|
||||
|
||||
@@ -255,10 +261,7 @@ func (uc *UserController) getUserProfilePictureHandler(c *gin.Context) {
|
||||
defer picture.Close()
|
||||
}
|
||||
|
||||
_, ok := c.GetQuery("skipCache")
|
||||
if !ok {
|
||||
c.Header("Cache-Control", "public, max-age=900")
|
||||
}
|
||||
utils.SetCacheControlHeader(c, 15*time.Minute, 1*time.Hour)
|
||||
|
||||
c.DataFromReader(http.StatusOK, size, "image/png", picture, nil)
|
||||
}
|
||||
@@ -333,10 +336,17 @@ func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bo
|
||||
return
|
||||
}
|
||||
|
||||
var ttl time.Duration
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
} else {
|
||||
ttl = input.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
}
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, input.ExpiresAt)
|
||||
token, err := uc.userService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -380,7 +390,7 @@ func (uc *UserController) createAdminOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
// @Router /api/one-time-access-email [post]
|
||||
func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(c *gin.Context) {
|
||||
var input dto.OneTimeAccessEmailAsUnauthenticatedUserDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -413,7 +423,11 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
|
||||
|
||||
userID := c.Param("id")
|
||||
|
||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, input.ExpiresAt)
|
||||
ttl := input.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
err := uc.userService.RequestOneTimeAccessEmailAsAdmin(c.Request.Context(), userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -448,14 +462,23 @@ func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, userDto)
|
||||
}
|
||||
|
||||
// getSetupAccessTokenHandler godoc
|
||||
// @Summary Setup initial admin
|
||||
// @Description Generate setup access token for initial admin user configuration
|
||||
// signUpInitialAdmin godoc
|
||||
// @Summary Sign up initial admin user
|
||||
// @Description Sign up and generate setup access token for initial admin user
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body dto.SignUpDto true "User information"
|
||||
// @Success 200 {object} dto.UserDto
|
||||
// @Router /api/one-time-access-token/setup [post]
|
||||
func (uc *UserController) getSetupAccessTokenHandler(c *gin.Context) {
|
||||
user, token, err := uc.userService.SetupInitialAdmin(c.Request.Context())
|
||||
// @Router /api/signup/setup [post]
|
||||
func (uc *UserController) signUpInitialAdmin(c *gin.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 {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -503,10 +526,138 @@ func (uc *UserController) updateUserGroups(c *gin.Context) {
|
||||
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
|
||||
func (uc *UserController) updateUser(c *gin.Context, updateOwnUser bool) {
|
||||
var input dto.UserCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -40,10 +40,10 @@ type UserGroupController struct {
|
||||
// @Description Get a paginated list of user groups with optional search and sorting
|
||||
// @Tags User Groups
|
||||
// @Param search query string false "Search term to filter user groups by name"
|
||||
// @Param page query int false "Page number, starting from 1" default(1)
|
||||
// @Param limit query int false "Number of items per page" default(10)
|
||||
// @Param sort_column query string false "Column to sort by" default("name")
|
||||
// @Param sort_direction query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Param pagination[page] query int false "Page number for pagination" default(1)
|
||||
// @Param pagination[limit] query int false "Number of items per page" default(20)
|
||||
// @Param sort[column] query string false "Column to sort by"
|
||||
// @Param sort[direction] query string false "Sort direction (asc or desc)" default("asc")
|
||||
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
|
||||
// @Router /api/user-groups [get]
|
||||
func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
@@ -92,7 +92,6 @@ func (ugc *UserGroupController) list(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||
// @Security BearerAuth
|
||||
// @Router /api/user-groups/{id} [get]
|
||||
func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
|
||||
@@ -118,11 +117,10 @@ func (ugc *UserGroupController) get(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/user-groups [post]
|
||||
func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -151,11 +149,10 @@ func (ugc *UserGroupController) create(c *gin.Context) {
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
|
||||
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/user-groups/{id} [put]
|
||||
func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
var input dto.UserGroupCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
if err := dto.ShouldBindWithNormalizedJSON(c, &input); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -183,7 +180,6 @@ func (ugc *UserGroupController) update(c *gin.Context) {
|
||||
// @Produce json
|
||||
// @Param id path string true "User Group ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Security BearerAuth
|
||||
// @Router /api/user-groups/{id} [delete]
|
||||
func (ugc *UserGroupController) delete(c *gin.Context) {
|
||||
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 users body dto.UserGroupUpdateUsersDto true "List of user IDs to assign to this group"
|
||||
// @Success 200 {object} dto.UserGroupDtoWithUsers
|
||||
// @Security BearerAuth
|
||||
// @Router /api/user-groups/{id}/users [put]
|
||||
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
|
||||
var input dto.UserGroupUpdateUsersDto
|
||||
|
||||
@@ -25,6 +25,8 @@ func NewWebauthnController(group *gin.RouterGroup, authMiddleware *middleware.Au
|
||||
|
||||
group.POST("/webauthn/logout", authMiddleware.WithAdminNotRequired().Add(), wc.logoutHandler)
|
||||
|
||||
group.POST("/webauthn/reauthenticate", authMiddleware.WithAdminNotRequired().Add(), rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), wc.reauthenticateHandler)
|
||||
|
||||
group.GET("/webauthn/credentials", authMiddleware.WithAdminNotRequired().Add(), wc.listCredentialsHandler)
|
||||
group.PATCH("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.updateCredentialHandler)
|
||||
group.DELETE("/webauthn/credentials/:id", authMiddleware.WithAdminNotRequired().Add(), wc.deleteCredentialHandler)
|
||||
@@ -171,3 +173,33 @@ func (wc *WebauthnController) logoutHandler(c *gin.Context) {
|
||||
cookie.AddAccessTokenCookie(c, 0, "")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (wc *WebauthnController) reauthenticateHandler(c *gin.Context) {
|
||||
sessionID, err := c.Cookie(cookie.SessionIdCookieName)
|
||||
if err != nil {
|
||||
_ = c.Error(&common.MissingSessionIdError{})
|
||||
return
|
||||
}
|
||||
|
||||
var token string
|
||||
|
||||
// Try to create a reauthentication token with WebAuthn
|
||||
credentialAssertionData, err := protocol.ParseCredentialRequestResponseBody(c.Request.Body)
|
||||
if err == nil {
|
||||
token, err = wc.webAuthnService.CreateReauthenticationTokenWithWebauthn(c.Request.Context(), sessionID, credentialAssertionData)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// If WebAuthn fails, try to create a reauthentication token with the access token
|
||||
accessToken, _ := c.Cookie(cookie.AccessTokenCookieName)
|
||||
token, err = wc.webAuthnService.CreateReauthenticationTokenWithAccessToken(c.Request.Context(), accessToken)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"reauthenticationToken": token})
|
||||
}
|
||||
|
||||
@@ -3,8 +3,9 @@ package controller
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
@@ -23,7 +24,9 @@ func NewWellKnownController(group *gin.RouterGroup, jwtService *service.JwtServi
|
||||
var err error
|
||||
wkc.oidcConfig, err = wkc.computeOIDCConfiguration()
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to pre-compute OpenID Connect configuration document: %v", err)
|
||||
slog.Error("Failed to pre-compute OpenID Connect configuration document", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
group.GET("/.well-known/jwks.json", wkc.jwksHandler)
|
||||
@@ -69,20 +72,22 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
return nil, fmt.Errorf("failed to get key algorithm: %w", err)
|
||||
}
|
||||
config := map[string]any{
|
||||
"issuer": appUrl,
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": appUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
||||
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"grant_types_supported": []string{"authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||
"issuer": appUrl,
|
||||
"authorization_endpoint": appUrl + "/authorize",
|
||||
"token_endpoint": appUrl + "/api/oidc/token",
|
||||
"userinfo_endpoint": appUrl + "/api/oidc/userinfo",
|
||||
"end_session_endpoint": appUrl + "/api/oidc/end-session",
|
||||
"introspection_endpoint": appUrl + "/api/oidc/introspect",
|
||||
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
|
||||
"jwks_uri": appUrl + "/.well-known/jwks.json",
|
||||
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode},
|
||||
"scopes_supported": []string{"openid", "profile", "email", "groups"},
|
||||
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
|
||||
"response_types_supported": []string{"code", "id_token"},
|
||||
"subject_types_supported": []string{"public"},
|
||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||
"authorization_response_iss_parameter_supported": true,
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
@@ -5,15 +5,15 @@ import (
|
||||
)
|
||||
|
||||
type ApiKeyCreateDto struct {
|
||||
Name string `json:"name" binding:"required,min=3,max=50"`
|
||||
Description string `json:"description"`
|
||||
Name string `json:"name" binding:"required,min=3,max=50" unorm:"nfc"`
|
||||
Description *string `json:"description" unorm:"nfc"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt" binding:"required"`
|
||||
}
|
||||
|
||||
type ApiKeyDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Description *string `json:"description"`
|
||||
ExpiresAt datatype.DateTime `json:"expiresAt"`
|
||||
LastUsedAt *datatype.DateTime `json:"lastUsedAt"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
|
||||
@@ -12,11 +12,15 @@ type AppConfigVariableDto struct {
|
||||
}
|
||||
|
||||
type AppConfigUpdateDto struct {
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30"`
|
||||
AppName string `json:"appName" binding:"required,min=1,max=30" unorm:"nfc"`
|
||||
SessionDuration string `json:"sessionDuration" binding:"required"`
|
||||
EmailsVerified string `json:"emailsVerified" binding:"required"`
|
||||
DisableAnimations string `json:"disableAnimations" binding:"required"`
|
||||
AllowOwnAccountEdit string `json:"allowOwnAccountEdit" binding:"required"`
|
||||
AllowUserSignups string `json:"allowUserSignups" binding:"required,oneof=disabled withToken open"`
|
||||
SignupDefaultUserGroupIDs string `json:"signupDefaultUserGroupIDs" binding:"omitempty,json"`
|
||||
SignupDefaultCustomClaims string `json:"signupDefaultCustomClaims" binding:"omitempty,json"`
|
||||
AccentColor string `json:"accentColor"`
|
||||
SmtpHost string `json:"smtpHost"`
|
||||
SmtpPort string `json:"smtpPort"`
|
||||
SmtpFrom string `json:"smtpFrom" binding:"omitempty,email"`
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
@@ -9,18 +8,19 @@ type AuditLogDto struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
|
||||
Event model.AuditLogEvent `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
UserID string `json:"userID"`
|
||||
Username string `json:"username"`
|
||||
Data model.AuditLogData `json:"data"`
|
||||
Event string `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
UserID string `json:"userID"`
|
||||
Username string `json:"username"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
type AuditLogFilterDto struct {
|
||||
UserID string `form:"filters[userId]"`
|
||||
Event string `form:"filters[event]"`
|
||||
ClientName string `form:"filters[clientName]"`
|
||||
Location string `form:"filters[location]"`
|
||||
}
|
||||
|
||||
@@ -6,6 +6,6 @@ type CustomClaimDto struct {
|
||||
}
|
||||
|
||||
type CustomClaimCreateDto struct {
|
||||
Key string `json:"key" binding:"required"`
|
||||
Value string `json:"value" binding:"required"`
|
||||
Key string `json:"key" binding:"required" unorm:"nfc"`
|
||||
Value string `json:"value" binding:"required" unorm:"nfc"`
|
||||
}
|
||||
|
||||
@@ -1,109 +1,27 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"time"
|
||||
"fmt"
|
||||
|
||||
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
|
||||
func MapStructList[S any, D any](source []S, destination *[]D) error {
|
||||
*destination = make([]D, 0, len(source))
|
||||
func MapStructList[S any, D any](source []S, destination *[]D) (err error) {
|
||||
*destination = make([]D, len(source))
|
||||
|
||||
for _, item := range source {
|
||||
var destItem D
|
||||
if err := MapStruct(item, &destItem); err != nil {
|
||||
return err
|
||||
for i, item := range source {
|
||||
err = MapStruct(item, &((*destination)[i]))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to map field %d: %w", i, err)
|
||||
}
|
||||
*destination = append(*destination, destItem)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MapStruct maps a source struct to a destination struct
|
||||
func MapStruct[S any, D any](source S, destination *D) error {
|
||||
// Ensure destination is a non-nil pointer
|
||||
destValue := reflect.ValueOf(destination)
|
||||
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
|
||||
func MapStruct(source any, destination any) error {
|
||||
return copier.CopyWithOption(destination, source, copier.Option{
|
||||
DeepCopy: true,
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
|
||||
type OidcClientMetaDataDto struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
HasLogo bool `json:"hasLogo"`
|
||||
LaunchURL *string `json:"launchURL"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
}
|
||||
|
||||
type OidcClientDto struct {
|
||||
OidcClientMetaDataDto
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedUserGroupsDto struct {
|
||||
@@ -19,26 +24,52 @@ type OidcClientWithAllowedUserGroupsDto struct {
|
||||
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
|
||||
}
|
||||
|
||||
type OidcClientWithAllowedGroupsCountDto struct {
|
||||
OidcClientDto
|
||||
AllowedUserGroupsCount int64 `json:"allowedUserGroupsCount"`
|
||||
}
|
||||
|
||||
type OidcClientUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
Credentials OidcClientCredentialsDto `json:"credentials"`
|
||||
LaunchURL *string `json:"launchURL" binding:"omitempty,url"`
|
||||
}
|
||||
|
||||
type OidcClientCreateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"required"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
OidcClientUpdateDto
|
||||
ID string `json:"id" binding:"omitempty,client_id,min=2,max=128"`
|
||||
}
|
||||
|
||||
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 {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
Code string `json:"code"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
Issuer string `json:"issuer"`
|
||||
}
|
||||
|
||||
type AuthorizationRequiredDto struct {
|
||||
@@ -47,13 +78,15 @@ type AuthorizationRequiredDto struct {
|
||||
}
|
||||
|
||||
type OidcCreateTokensDto struct {
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code"`
|
||||
DeviceCode string `form:"device_code"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
RefreshToken string `form:"refresh_token"`
|
||||
GrantType string `form:"grant_type" binding:"required"`
|
||||
Code string `form:"code"`
|
||||
DeviceCode string `form:"device_code"`
|
||||
ClientID string `form:"client_id"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
CodeVerifier string `form:"code_verifier"`
|
||||
RefreshToken string `form:"refresh_token"`
|
||||
ClientAssertion string `form:"client_assertion"`
|
||||
ClientAssertionType string `form:"client_assertion_type"`
|
||||
}
|
||||
|
||||
type OidcIntrospectDto struct {
|
||||
@@ -93,9 +126,11 @@ type OidcIntrospectionResponseDto struct {
|
||||
}
|
||||
|
||||
type OidcDeviceAuthorizationRequestDto struct {
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
Scope string `form:"scope" binding:"required"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
ClientID string `form:"client_id" binding:"required"`
|
||||
Scope string `form:"scope" binding:"required"`
|
||||
ClientSecret string `form:"client_secret"`
|
||||
ClientAssertion string `form:"client_assertion"`
|
||||
ClientAssertionType string `form:"client_assertion_type"`
|
||||
}
|
||||
|
||||
type OidcDeviceAuthorizationResponseDto struct {
|
||||
@@ -120,3 +155,20 @@ type DeviceCodeInfoDto struct {
|
||||
AuthorizationRequired bool `json:"authorizationRequired"`
|
||||
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,8 @@
|
||||
package dto
|
||||
|
||||
import "time"
|
||||
import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type UserDto struct {
|
||||
ID string `json:"id"`
|
||||
@@ -17,10 +19,10 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50"`
|
||||
LastName string `json:"lastName" binding:"max=50"`
|
||||
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"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
@@ -28,19 +30,27 @@ type UserCreateDto struct {
|
||||
}
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId"`
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
UserID string `json:"userId"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Email string `json:"email" binding:"required,email" unorm:"nfc"`
|
||||
RedirectPath string `json:"redirectPath"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsAdminDto struct {
|
||||
ExpiresAt time.Time `json:"expiresAt" binding:"required"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
}
|
||||
|
||||
type UserUpdateUserGroupDto struct {
|
||||
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"`
|
||||
}
|
||||
|
||||
@@ -34,8 +34,8 @@ type UserGroupDtoWithUserCount struct {
|
||||
}
|
||||
|
||||
type UserGroupCreateDto struct {
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255"`
|
||||
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
|
||||
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`
|
||||
LdapID string `json:"-"`
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +1,52 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"log"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
"github.com/gin-gonic/gin/binding"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
var validateUsername validator.Func = func(fl validator.FieldLevel) bool {
|
||||
func init() {
|
||||
v := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||
regex := "^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$"
|
||||
matched, _ := regexp.MatchString(regex, fl.Field().String())
|
||||
return matched
|
||||
}
|
||||
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
|
||||
|
||||
func init() {
|
||||
if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
|
||||
if err := v.RegisterValidation("username", validateUsername); err != nil {
|
||||
log.Fatalf("Failed to register custom validation: %v", err)
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
// Maximum allowed value for TTLs
|
||||
const maxTTL = 31 * 24 * time.Hour
|
||||
|
||||
// Errors here are development-time ones
|
||||
err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||
return validateUsernameRegex.MatchString(fl.Field().String())
|
||||
})
|
||||
if err != nil {
|
||||
panic("Failed to register custom validation for username: " + err.Error())
|
||||
}
|
||||
|
||||
err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
|
||||
return validateClientIDRegex.MatchString(fl.Field().String())
|
||||
})
|
||||
if err != nil {
|
||||
panic("Failed to register custom validation for client_id: " + err.Error())
|
||||
}
|
||||
|
||||
err = v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Allow zero, which means the field wasn't set
|
||||
return ttl.Duration == 0 || ttl.Duration > time.Second && ttl.Duration <= maxTTL
|
||||
})
|
||||
if err != nil {
|
||||
panic("Failed to register custom validation for ttl: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@ type WebauthnCredentialDto struct {
|
||||
}
|
||||
|
||||
type WebauthnCredentialUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,min=1,max=30"`
|
||||
Name string `json:"name" binding:"required,min=1,max=50"`
|
||||
}
|
||||
|
||||
86
backend/internal/job/analytics_job.go
Normal file
86
backend/internal/job/analytics_job.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package job
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
backoff "github.com/cenkalti/backoff/v5"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat"
|
||||
|
||||
func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error {
|
||||
// Skip if analytics are disabled or not in production environment
|
||||
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Send every 24 hours
|
||||
jobs := &AnalyticsJob{
|
||||
appConfig: appConfig,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
return s.registerJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
||||
}
|
||||
|
||||
type AnalyticsJob struct {
|
||||
appConfig *service.AppConfigService
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// sendHeartbeat sends a heartbeat to the analytics service
|
||||
func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error {
|
||||
// Skip if analytics are disabled or not in production environment
|
||||
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
|
||||
return nil
|
||||
}
|
||||
|
||||
body, err := json.Marshal(struct {
|
||||
Version string `json:"version"`
|
||||
InstanceID string `json:"instance_id"`
|
||||
}{
|
||||
Version: common.Version,
|
||||
InstanceID: j.appConfig.GetDbConfig().InstanceID.Value,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal heartbeat body: %w", err)
|
||||
}
|
||||
|
||||
_, err = backoff.Retry(
|
||||
parentCtx,
|
||||
func() (struct{}, error) {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return struct{}{}, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := j.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return struct{}{}, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return struct{}{}, fmt.Errorf("request failed with status code: %d", resp.StatusCode)
|
||||
}
|
||||
return struct{}{}, nil
|
||||
},
|
||||
backoff.WithBackOff(backoff.NewExponentialBackOff()),
|
||||
backoff.WithMaxTries(3),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("heartbeat request failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -2,7 +2,10 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
@@ -18,7 +21,8 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
|
||||
return s.registerJob(ctx, "ExpiredApiKeyEmailJob", "0 0 * * *", jobs.checkAndNotifyExpiringApiKeys)
|
||||
// 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 {
|
||||
@@ -29,16 +33,16 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
||||
|
||||
apiKeys, err := j.apiKeyService.ListExpiringApiKeys(ctx, 7)
|
||||
if err != nil {
|
||||
log.Printf("Failed to list expiring API keys: %v", err)
|
||||
return err
|
||||
return fmt.Errorf("failed to list expiring API keys: %w", err)
|
||||
}
|
||||
|
||||
for _, key := range apiKeys {
|
||||
if key.User.Email == "" {
|
||||
continue
|
||||
}
|
||||
if err := j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key); err != nil {
|
||||
log.Printf("Failed to send email for key %s: %v", key.ID, err)
|
||||
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -3,8 +3,11 @@ package job
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
@@ -14,12 +17,16 @@ import (
|
||||
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||
jobs := &DbCleanupJobs{db: db}
|
||||
|
||||
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
||||
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
||||
return errors.Join(
|
||||
s.registerJob(ctx, "ClearWebauthnSessions", "0 3 * * *", jobs.clearWebauthnSessions),
|
||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", "0 3 * * *", jobs.clearOneTimeAccessTokens),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", "0 3 * * *", jobs.clearOidcAuthorizationCodes),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", "0 3 * * *", jobs.clearOidcRefreshTokens),
|
||||
s.registerJob(ctx, "ClearAuditLogs", "0 3 * * *", jobs.clearAuditLogs),
|
||||
s.registerJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.registerJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.registerJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.registerJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.registerJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.registerJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||
s.registerJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,40 +36,99 @@ type DbCleanupJobs struct {
|
||||
|
||||
// ClearWebauthnSessions deletes WebAuthn sessions that have expired
|
||||
func (j *DbCleanupJobs) clearWebauthnSessions(ctx context.Context) error {
|
||||
return j.db.
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
||||
Error
|
||||
Delete(&model.WebauthnSession{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired WebAuthn sessions: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired WebAuthn sessions", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearOneTimeAccessTokens deletes one-time access tokens that have expired
|
||||
func (j *DbCleanupJobs) clearOneTimeAccessTokens(ctx context.Context) error {
|
||||
return j.db.
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
||||
Error
|
||||
Delete(&model.OneTimeAccessToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired one-time access tokens: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired one-time access tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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
|
||||
func (j *DbCleanupJobs) clearOidcAuthorizationCodes(ctx context.Context) error {
|
||||
return j.db.
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
||||
Error
|
||||
Delete(&model.OidcAuthorizationCode{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired OIDC authorization codes: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired OIDC authorization codes", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearOidcAuthorizationCodes deletes OIDC authorization codes that have expired
|
||||
func (j *DbCleanupJobs) clearOidcRefreshTokens(ctx context.Context) error {
|
||||
return j.db.
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now())).
|
||||
Error
|
||||
Delete(&model.OidcRefreshToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired OIDC refresh tokens: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired OIDC refresh tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearReauthenticationTokens deletes reauthentication tokens that have expired
|
||||
func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.ReauthenticationToken{}, "expires_at < ?", datatype.DateTime(time.Now()))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to clean expired reauthentication tokens: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Cleaned expired reauthentication tokens", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearAuditLogs deletes audit logs older than 90 days
|
||||
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
|
||||
return j.db.
|
||||
st := j.db.
|
||||
WithContext(ctx).
|
||||
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90))).
|
||||
Error
|
||||
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(time.Now().AddDate(0, 0, -90)))
|
||||
if st.Error != nil {
|
||||
return fmt.Errorf("failed to delete old audit logs: %w", st.Error)
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "Deleted old audit logs", slog.Int64("count", st.RowsAffected))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,11 +3,13 @@ package job
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -17,7 +19,8 @@ import (
|
||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||
jobs := &FileCleanupJobs{db: db}
|
||||
|
||||
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", "0 2 * * 0", jobs.clearUnusedDefaultProfilePictures)
|
||||
// Run every 24 hours
|
||||
return s.registerJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
||||
}
|
||||
|
||||
type FileCleanupJobs struct {
|
||||
@@ -64,13 +67,13 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
||||
if _, ok := initialsInUse[initials]; !ok {
|
||||
filePath := filepath.Join(defaultPicturesDir, filename)
|
||||
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 {
|
||||
filesDeleted++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Printf("Deleted %d unused default profile pictures", filesDeleted)
|
||||
slog.Info("Done deleting unused default profile pictures", slog.Int("count", filesDeleted))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -21,23 +22,8 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
||||
|
||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||
|
||||
// Register the job to run every day, at 5 minutes past midnight
|
||||
err := s.registerJob(ctx, "UpdateGeoLiteDB", "5 * */1 * *", jobs.updateGoeLiteDB)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run the job immediately on startup, with a 1s delay
|
||||
go func() {
|
||||
time.Sleep(time.Second)
|
||||
err = jobs.updateGoeLiteDB(ctx)
|
||||
if err != nil {
|
||||
// Log the error only, but don't return it
|
||||
log.Printf("Failed to Update GeoLite database: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
// Run every 24 hours (and right away)
|
||||
return s.registerJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
||||
}
|
||||
|
||||
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||
|
||||
@@ -2,7 +2,9 @@ package job
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
@@ -16,19 +18,7 @@ func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.L
|
||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||
|
||||
// Register the job to run every hour
|
||||
err := s.registerJob(ctx, "SyncLdap", "0 * * * *", jobs.syncLdap)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Run the job immediately on startup
|
||||
err = jobs.syncLdap(ctx)
|
||||
if err != nil {
|
||||
// Log the error only, but don't return it
|
||||
log.Printf("Failed to sync LDAP: %v", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
return s.registerJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
||||
}
|
||||
|
||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||
|
||||
@@ -3,7 +3,7 @@ package job
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
@@ -27,7 +27,7 @@ func NewScheduler() (*Scheduler, error) {
|
||||
// Run the scheduler.
|
||||
// This function blocks until the context is canceled.
|
||||
func (s *Scheduler) Run(ctx context.Context) error {
|
||||
log.Println("Starting job scheduler")
|
||||
slog.Info("Starting job scheduler")
|
||||
s.scheduler.Start()
|
||||
|
||||
// Block until context is canceled
|
||||
@@ -35,28 +35,45 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
||||
|
||||
err := s.scheduler.Shutdown()
|
||||
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 {
|
||||
log.Println("Job scheduler shut down")
|
||||
slog.Info("Job scheduler shut down")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) registerJob(ctx context.Context, name string, interval string, job func(ctx context.Context) error) error {
|
||||
_, err := s.scheduler.NewJob(
|
||||
gocron.CronJob(interval, false),
|
||||
gocron.NewTask(job),
|
||||
func (s *Scheduler) registerJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool) error {
|
||||
jobOptions := []gocron.JobOption{
|
||||
gocron.WithContext(ctx),
|
||||
gocron.WithEventListeners(
|
||||
gocron.BeforeJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||
slog.Info("Starting job",
|
||||
slog.String("name", name),
|
||||
slog.String("id", jobID.String()),
|
||||
)
|
||||
}),
|
||||
gocron.AfterJobRuns(func(jobID uuid.UUID, jobName string) {
|
||||
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) {
|
||||
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),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if runImmediately {
|
||||
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||
}
|
||||
|
||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register job %q: %w", name, err)
|
||||
|
||||
@@ -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-Headers", "Authorization")
|
||||
c.Writer.Header().Set("Access-Control-Allow-Methods", "GET, POST")
|
||||
|
||||
// Preflight request
|
||||
|
||||
@@ -29,7 +29,7 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
|
||||
|
||||
// Skip rate limiting for localhost and test environment
|
||||
// If the client ip is localhost the request comes from the frontend
|
||||
if ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
||||
if ip == "" || ip == "127.0.0.1" || ip == "::1" || common.EnvConfig.AppEnv == "test" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,9 +4,12 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
type AppConfigVariable struct {
|
||||
@@ -31,21 +34,26 @@ func (a *AppConfigVariable) AsDurationMinutes() time.Duration {
|
||||
|
||||
type AppConfig struct {
|
||||
// General
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AppName AppConfigVariable `key:"appName,public"` // Public
|
||||
SessionDuration AppConfigVariable `key:"sessionDuration"`
|
||||
EmailsVerified AppConfigVariable `key:"emailsVerified"`
|
||||
AccentColor AppConfigVariable `key:"accentColor,public"` // Public
|
||||
DisableAnimations AppConfigVariable `key:"disableAnimations,public"` // Public
|
||||
AllowOwnAccountEdit AppConfigVariable `key:"allowOwnAccountEdit,public"` // Public
|
||||
AllowUserSignups AppConfigVariable `key:"allowUserSignups,public"` // Public
|
||||
SignupDefaultUserGroupIDs AppConfigVariable `key:"signupDefaultUserGroupIDs"`
|
||||
SignupDefaultCustomClaims AppConfigVariable `key:"signupDefaultCustomClaims"`
|
||||
// Internal
|
||||
BackgroundImageType AppConfigVariable `key:"backgroundImageType,internal"` // Internal
|
||||
LogoLightImageType AppConfigVariable `key:"logoLightImageType,internal"` // Internal
|
||||
LogoDarkImageType AppConfigVariable `key:"logoDarkImageType,internal"` // Internal
|
||||
InstanceID AppConfigVariable `key:"instanceId,internal"` // Internal
|
||||
// Email
|
||||
SmtpHost AppConfigVariable `key:"smtpHost"`
|
||||
SmtpPort AppConfigVariable `key:"smtpPort"`
|
||||
SmtpFrom AppConfigVariable `key:"smtpFrom"`
|
||||
SmtpUser AppConfigVariable `key:"smtpUser"`
|
||||
SmtpPassword AppConfigVariable `key:"smtpPassword"`
|
||||
SmtpPassword AppConfigVariable `key:"smtpPassword,sensitive"`
|
||||
SmtpTls AppConfigVariable `key:"smtpTls"`
|
||||
SmtpSkipCertVerify AppConfigVariable `key:"smtpSkipCertVerify"`
|
||||
EmailLoginNotificationEnabled AppConfigVariable `key:"emailLoginNotificationEnabled"`
|
||||
@@ -56,7 +64,7 @@ type AppConfig struct {
|
||||
LdapEnabled AppConfigVariable `key:"ldapEnabled,public"` // Public
|
||||
LdapUrl AppConfigVariable `key:"ldapUrl"`
|
||||
LdapBindDn AppConfigVariable `key:"ldapBindDn"`
|
||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword"`
|
||||
LdapBindPassword AppConfigVariable `key:"ldapBindPassword,sensitive"`
|
||||
LdapBase AppConfigVariable `key:"ldapBase"`
|
||||
LdapUserSearchFilter AppConfigVariable `key:"ldapUserSearchFilter"`
|
||||
LdapUserGroupSearchFilter AppConfigVariable `key:"ldapUserGroupSearchFilter"`
|
||||
@@ -74,7 +82,7 @@ type AppConfig struct {
|
||||
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
|
||||
cfgValue := reflect.ValueOf(c).Elem()
|
||||
cfgType := cfgValue.Type()
|
||||
@@ -94,11 +102,16 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
||||
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{
|
||||
Key: key,
|
||||
Value: fieldValue.FieldByName("Value").String(),
|
||||
Value: value,
|
||||
}
|
||||
|
||||
res = append(res, appConfigVariable)
|
||||
@@ -107,24 +120,26 @@ func (c *AppConfig) ToAppConfigVariableSlice(showAll bool) []AppConfigVariable {
|
||||
return res
|
||||
}
|
||||
|
||||
func (c *AppConfig) FieldByKey(key string) (string, error) {
|
||||
func (c *AppConfig) FieldByKey(key string) (defaultValue string, isInternal bool, err error) {
|
||||
rv := reflect.ValueOf(c).Elem()
|
||||
rt := rv.Type()
|
||||
|
||||
// Find the field in the struct whose "key" tag matches
|
||||
for i := range rt.NumField() {
|
||||
// Grab only the first part of the key, if there's a comma with additional properties
|
||||
tagValue, _, _ := strings.Cut(rt.Field(i).Tag.Get("key"), ",")
|
||||
if tagValue != key {
|
||||
tagValue := strings.Split(rt.Field(i).Tag.Get("key"), ",")
|
||||
keyFromTag := tagValue[0]
|
||||
isInternal = slices.Contains(tagValue, "internal")
|
||||
if keyFromTag != key {
|
||||
continue
|
||||
}
|
||||
|
||||
valueField := rv.Field(i).FieldByName("Value")
|
||||
return valueField.String(), nil
|
||||
return valueField.String(), isInternal, nil
|
||||
}
|
||||
|
||||
// If we are here, the config key was not found
|
||||
return "", AppConfigKeyNotFoundError{field: key}
|
||||
return "", false, AppConfigKeyNotFoundError{field: key}
|
||||
}
|
||||
|
||||
func (c *AppConfig) UpdateField(key string, value string, noInternal bool) error {
|
||||
@@ -165,7 +180,7 @@ type AppConfigKeyNotFoundError struct {
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Error() string {
|
||||
return fmt.Sprintf("cannot find config key '%s'", e.field)
|
||||
return "cannot find config key '" + e.field + "'"
|
||||
}
|
||||
|
||||
func (e AppConfigKeyNotFoundError) Is(target error) bool {
|
||||
@@ -179,7 +194,7 @@ type AppConfigInternalForbiddenError struct {
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Error() string {
|
||||
return fmt.Sprintf("field '%s' is internal and can't be updated", e.field)
|
||||
return "field '" + e.field + "' is internal and can't be updated"
|
||||
}
|
||||
|
||||
func (e AppConfigInternalForbiddenError) Is(target error) bool {
|
||||
|
||||
@@ -103,7 +103,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
|
||||
|
||||
// Verify every AppConfig field has a matching DTO field with the same name
|
||||
for fieldName, keyName := range appConfigFields {
|
||||
if strings.HasSuffix(fieldName, "ImageType") {
|
||||
if strings.HasSuffix(fieldName, "ImageType") || keyName == "instanceId" {
|
||||
// Skip internal fields that shouldn't be in the DTO
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ type AuditLog struct {
|
||||
Base
|
||||
|
||||
Event AuditLogEvent `sortable:"true"`
|
||||
IpAddress string `sortable:"true"`
|
||||
IpAddress *string `sortable:"true"`
|
||||
Country string `sortable:"true"`
|
||||
City string `sortable:"true"`
|
||||
UserAgent string `sortable:"true"`
|
||||
@@ -28,6 +28,7 @@ type AuditLogEvent string //nolint:recvcheck
|
||||
const (
|
||||
AuditLogEventSignIn AuditLogEvent = "SIGN_IN"
|
||||
AuditLogEventOneTimeAccessTokenSignIn AuditLogEvent = "TOKEN_SIGN_IN"
|
||||
AuditLogEventAccountCreated AuditLogEvent = "ACCOUNT_CREATED"
|
||||
AuditLogEventClientAuthorization AuditLogEvent = "CLIENT_AUTHORIZATION"
|
||||
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_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"
|
||||
}
|
||||
@@ -5,12 +5,15 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"gorm.io/gorm"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
)
|
||||
|
||||
type UserAuthorizedOidcClient struct {
|
||||
Scope string
|
||||
Scope string
|
||||
LastUsedAt datatype.DateTime `sortable:"true"`
|
||||
|
||||
UserID string `gorm:"primary_key;"`
|
||||
User User
|
||||
|
||||
@@ -37,18 +40,22 @@ type OidcAuthorizationCode struct {
|
||||
type OidcClient struct {
|
||||
Base
|
||||
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
Name string `sortable:"true"`
|
||||
Secret string
|
||||
CallbackURLs UrlList
|
||||
LogoutCallbackURLs UrlList
|
||||
ImageType *string
|
||||
HasLogo bool `gorm:"-"`
|
||||
IsPublic bool
|
||||
PkceEnabled bool
|
||||
RequiresReauthentication bool
|
||||
Credentials OidcClientCredentials
|
||||
LaunchURL *string
|
||||
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID string
|
||||
CreatedBy User
|
||||
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
|
||||
CreatedByID *string
|
||||
CreatedBy *User
|
||||
UserAuthorizedOidcClients []UserAuthorizedOidcClient `gorm:"foreignKey:ClientID;references:ID"`
|
||||
}
|
||||
|
||||
type OidcRefreshToken struct {
|
||||
@@ -71,9 +78,49 @@ func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
|
||||
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
|
||||
|
||||
func (cu *UrlList) Scan(value interface{}) error {
|
||||
func (cu *UrlList) Scan(value any) error {
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
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()
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package datatype
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
@@ -10,9 +11,16 @@ import (
|
||||
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
|
||||
type DateTime time.Time //nolint:recvcheck
|
||||
|
||||
func (date *DateTime) Scan(value interface{}) (err error) {
|
||||
*date = DateTime(value.(time.Time))
|
||||
return
|
||||
func (date *DateTime) Scan(value any) (err error) {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
*date = DateTime(v)
|
||||
case int64:
|
||||
*date = DateTime(time.Unix(v, 0))
|
||||
default:
|
||||
return fmt.Errorf("unexpected type for DateTime: %T", value)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (date DateTime) Value() (driver.Value, error) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/go-webauthn/webauthn/webauthn"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
@@ -45,6 +45,15 @@ type PublicKeyCredentialRequestOptions struct {
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type ReauthenticationToken struct {
|
||||
Base
|
||||
Token string
|
||||
ExpiresAt datatype.DateTime
|
||||
|
||||
UserID string
|
||||
User User
|
||||
}
|
||||
|
||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user