Compare commits

..

1 Commits

Author SHA1 Message Date
Alessandro (Ale) Segala
419b7613ee Update example .env to use database for keys storage
Given recent conversations on how the database will be the preferred (or sole) method in a future release, start by updating the example env
2025-11-10 07:17:38 -08:00
292 changed files with 7336 additions and 13366 deletions

View File

@@ -1,18 +1,14 @@
# See the documentation for more information: https://pocket-id.org/docs/configuration/environment-variables
# These variables must be configured for your deployment:
APP_URL=https://your-pocket-id-domain.com
# Encryption key (choose one method):
# Method 1: Direct key (simple but less secure)
# Generate with: openssl rand -base64 32
ENCRYPTION_KEY=
# Method 2: File-based key (recommended)
# Put the base64 key in a file and point to it here.
# ENCRYPTION_KEY_FILE=/path/to/encryption_key
# These variables are optional but recommended to review:
TRUST_PROXY=false
MAXMIND_LICENSE_KEY=
PUID=1000
PGID=1000
PGID=1000
KEYS_STORAGE=database
# Encryption key used to protect sensitive data
# Can be generated with `openssl rand -base64 32`
# See https://pocket-id.org/docs/configuration/environment-variables#encryption-keys
ENCRYPTION_KEY=
# Alternatively, point to a file containing the key (could be a Docker secret)
#ENCRYPTION_KEY_FILE=

View File

@@ -6,7 +6,7 @@ on:
paths:
- "backend/**"
pull_request:
branches: [main, breaking/**]
branches: [main]
paths:
- "backend/**"

View File

@@ -7,7 +7,7 @@ on:
- "**.md"
- ".github/**"
pull_request:
branches: [main, breaking/**]
branches: [main]
paths-ignore:
- "docs/**"
- "**.md"
@@ -57,17 +57,7 @@ jobs:
strategy:
fail-fast: false
matrix:
include:
- db: sqlite
storage: filesystem
- db: postgres
storage: filesystem
- db: sqlite
storage: s3
- db: sqlite
storage: database
- db: postgres
storage: database
db: [sqlite, postgres, sqlite-s3]
steps:
- uses: actions/checkout@v5
@@ -81,88 +71,64 @@ jobs:
node-version: 22
- name: Cache Playwright Browsers
uses: actions/cache@v4
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
- name: Cache PostgreSQL Docker image
uses: actions/cache@v4
if: matrix.db == 'postgres'
uses: actions/cache@v3
id: postgres-cache
with:
path: /tmp/postgres-image.tar
key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image
if: 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
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@v4
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 lldap/lldap:2025-05-19
docker save lldap/lldap:2025-05-19 > /tmp/lldap-image.tar
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Cache SCIM Test Server Docker image
uses: actions/cache@v4
id: scim-cache
with:
path: /tmp/scim-test-server-image.tar
key: scim-test-server-${{ runner.os }}
- name: Pull and save SCIM Test Server image
if: steps.scim-cache.outputs.cache-hit != 'true'
run: |
docker pull ghcr.io/pocket-id/scim-test-server
docker save ghcr.io/pocket-id/scim-test-server > /tmp/scim-test-server-image.tar
- name: Load SCIM Test Server image
if: steps.scim-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/scim-test-server-image.tar
- name: Cache Localstack S3 Docker image
if: matrix.storage == 's3'
uses: actions/cache@v4
if: matrix.db == 'sqlite-s3'
uses: actions/cache@v3
id: s3-cache
with:
path: /tmp/localstack-s3-image.tar
key: localstack-s3-latest-${{ runner.os }}
- name: Pull and save Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit != 'true'
if: matrix.db == 'sqlite-s3' && steps.s3-cache.outputs.cache-hit != 'true'
run: |
docker pull localstack/localstack:s3-latest
docker save localstack/localstack:s3-latest > /tmp/localstack-s3-image.tar
- name: Load Localstack S3 image
if: matrix.storage == 's3' && steps.s3-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/localstack-s3-image.tar
- name: Cache AWS CLI Docker image
if: matrix.storage == 's3'
uses: actions/cache@v4
id: aws-cli-cache
with:
path: /tmp/aws-cli-image.tar
key: aws-cli-latest-${{ runner.os }}
- name: Pull and save AWS CLI image
if: matrix.storage == 's3' && steps.aws-cli-cache.outputs.cache-hit != 'true'
run: |
docker pull amazon/aws-cli:latest
docker save amazon/aws-cli:latest > /tmp/aws-cli-image.tar
- name: Load AWS CLI image
if: matrix.storage == 's3' && steps.aws-cli-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/aws-cli-image.tar
- name: Load Localstack S3 image
if: matrix.db == 'sqlite-s3' && steps.s3-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/localstack-s3-image.tar
- name: Download Docker image artifact
uses: actions/download-artifact@v4
@@ -181,38 +147,26 @@ jobs:
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm exec playwright install --with-deps chromium
- name: Run Docker containers
- name: Run Docker Container (sqlite) with LDAP
if: matrix.db == 'sqlite'
working-directory: ./tests/setup
run: |
DOCKER_COMPOSE_FILE=docker-compose.yml
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
cat > .env <<EOF
FILE_BACKEND=${{ matrix.storage }}
SCIM_SERVICE_PROVIDER_URL=http://localhost:18123/v2
SCIM_SERVICE_PROVIDER_URL_INTERNAL=http://scim-test-server:8080/v2
EOF
- name: Run Docker Container (postgres) with LDAP
if: matrix.db == 'postgres'
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-postgres.yml up -d
docker compose -f docker-compose-postgres.yml logs -f pocket-id &> /tmp/backend.log &
if [ "${{ matrix.db }}" = "postgres" ]; then
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
elif [ "${{ matrix.storage }}" = "s3" ]; then
DOCKER_COMPOSE_FILE=docker-compose-s3.yml
fi
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
{
LOG_FILE="/tmp/backend.log"
while true; do
CID=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q pocket-id)
if [ -n "$CID" ]; then
echo "[$(date)] Attaching logs for $CID" >> "$LOG_FILE"
docker logs -f --since=0 "$CID" >> "$LOG_FILE" 2>&1
else
echo "[$(date)] Container not yet running…" >> "$LOG_FILE"
fi
sleep 1
done
} &
- name: Run Docker Container (sqlite-s3) with LDAP + S3
if: matrix.db == 'sqlite-s3'
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-s3.yml up -d
docker compose -f docker-compose-s3.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./tests
@@ -222,7 +176,7 @@ jobs:
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-${{ matrix.db }}-${{ matrix.storage }}
name: playwright-report-${{ matrix.db }}
path: tests/.report
include-hidden-files: true
retention-days: 15
@@ -231,7 +185,7 @@ jobs:
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-${{ matrix.db }}-${{ matrix.storage }}
name: backend-${{ matrix.db }}
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15

2
.gitignore vendored
View File

@@ -15,7 +15,6 @@ node_modules
/backend/bin
pocket-id
/tests/test-results/*.json
.tmp/
# OS
.DS_Store
@@ -26,6 +25,7 @@ Thumbs.db
.env.*
!.env.development-example
!.env.test
!.env.example
# Vite
vite.config.js.timestamp-*

View File

@@ -1 +1 @@
2.0.0
1.15.0

4
.vscode/launch.json vendored
View File

@@ -5,14 +5,12 @@
"name": "Backend",
"type": "go",
"request": "launch",
"envFile": "${workspaceFolder}/backend/.env",
"envFile": "${workspaceFolder}/backend/cmd/.env",
"env": {
"APP_ENV": "development"
},
"mode": "debug",
"program": "${workspaceFolder}/backend/cmd/main.go",
"buildFlags": "-tags=exclude_frontend",
"cwd": "${workspaceFolder}/backend",
},
{
"name": "Frontend",

View File

@@ -1,88 +1,3 @@
## v2.0.0
### Bug Fixes
- update image format message to include WEBP ([#1133](https://github.com/pocket-id/pocket-id/pull/1133) by @sebdanielsson)
- add Japanese locale to inlang settings ([#1142](https://github.com/pocket-id/pocket-id/pull/1142) by @tai-ga)
- restrict email one time sign in token to same browser ([#1144](https://github.com/pocket-id/pocket-id/pull/1144) by @stonith404)
- rename `LDAP_ATTRIBUTE_ADMIN_GROUP` env variable to `LDAP_ADMIN_GROUP_NAME` ([e1c5021](https://github.com/pocket-id/pocket-id/commit/e1c5021eeedcbc54bad0eccd72d7ae760be61934) by @stonith404)
- make wildcard matching in callback URLs more stricter ([078152d](https://github.com/pocket-id/pocket-id/commit/078152d4dbb05dd027ff323f39d090ecb67927c7) by @stonith404)
- remove ambiguous characters from login code ([d9e7bf9](https://github.com/pocket-id/pocket-id/commit/d9e7bf9eef522d8c081fac2000bace6f95518039) by @stonith404)
- add missing translations to date picker ([894eaf3](https://github.com/pocket-id/pocket-id/commit/894eaf3cffdd9182b9c29e28b4dcb7e8bcbda26b) by @stonith404)
### Features
- add HTTP `HEAD` method support ([#1135](https://github.com/pocket-id/pocket-id/pull/1135) by @stonith404)
- add email logo customization ([#1150](https://github.com/pocket-id/pocket-id/pull/1150) by @MelvinSnijders)
- add ability define user groups for sign up tokens ([#1155](https://github.com/pocket-id/pocket-id/pull/1155) by @stonith404)
- minor redesign of auth pages ([08e4ffe](https://github.com/pocket-id/pocket-id/commit/08e4ffeb600a4a6644d91b1600b0205997ed1685) by @stonith404)
- allow audit log retention to be controlled by env variable ([#1158](https://github.com/pocket-id/pocket-id/pull/1158) by @jenic)
- restrict oidc clients by user groups per default ([#1164](https://github.com/pocket-id/pocket-id/pull/1164) by @stonith404)
- add "restricted" column to oidc client table ([1bc9f5f](https://github.com/pocket-id/pocket-id/commit/1bc9f5f7e780310d81608381544ba530df7f433b) by @stonith404)
- drop support for storing JWK on the filesystem ([f014458](https://github.com/pocket-id/pocket-id/commit/f0144584af90b918a3157a298f1bb95928a117b8) by @stonith404)
- add CLI command for importing and exporting Pocket ID data ([3420a00](https://github.com/pocket-id/pocket-id/commit/3420a000737d89a5c3c6c250d171d96126553beb) by @stonith404)
- remove DbProvider env variable and calculate it dynamically ([ba2f0f1](https://github.com/pocket-id/pocket-id/commit/ba2f0f18f4bacc5a86217dec0b0dcb6030c40cb9) by @kmendell)
- add support for SCIM provisioning ([#1182](https://github.com/pocket-id/pocket-id/pull/1182) by @stonith404)
### Other
- update AAGUIDs ([#1128](https://github.com/pocket-id/pocket-id/pull/1128) by @github-actions[bot])
- fix api key e2e test ([25f67bd](https://github.com/pocket-id/pocket-id/commit/25f67bd25a0ee0cab48d72107722e8c8428fa547) by @stonith404)
- update AAGUIDs ([#1140](https://github.com/pocket-id/pocket-id/pull/1140) by @github-actions[bot])
- upgrade dependencies ([90f555f](https://github.com/pocket-id/pocket-id/commit/90f555f7c12ff07545f7cd1a1754a8c19f5a4978) by @stonith404)
- fix type error after version bump ([edb32d8](https://github.com/pocket-id/pocket-id/commit/edb32d82b2c138433d8eb17d5a6a19f4728ae2d4) by @stonith404)
- remove `breaking/**` push trigger from actions ([461293b](https://github.com/pocket-id/pocket-id/commit/461293ba1da4ddbff2c77f23a42487b63964e474) by @stonith404)
- update AAGUIDs ([#1177](https://github.com/pocket-id/pocket-id/pull/1177) by @github-actions[bot])
- preparation for merge into main branch ([#1113](https://github.com/pocket-id/pocket-id/pull/1113) by @stonith404)
- bump pnpm to version 10.27.0 ([#1183](https://github.com/pocket-id/pocket-id/pull/1183) by @kmendell)
- update forms and other areas to use new shadcn components ([#1115](https://github.com/pocket-id/pocket-id/pull/1115) by @kmendell)
- run formatter ([e4a8ca4](https://github.com/pocket-id/pocket-id/commit/e4a8ca476cc3c7e8d8cdc8de21b5d7d99d07f7a0) by @stonith404)
- upgrade dependencies ([4776b70](https://github.com/pocket-id/pocket-id/commit/4776b70d96f3dc291394dc79c941738bbe48199a) by @stonith404)
- change translation string in e2e tests ([ffb2ef9](https://github.com/pocket-id/pocket-id/commit/ffb2ef91bd7bbe78eb29e86cd3675b695e821498) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.16.0...v2.0.0
## v1.16.0
### Bug Fixes
- use `quoted-printable` encoding for mails to prevent line limitation ([5cf73e9](https://github.com/pocket-id/pocket-id/commit/5cf73e9309640d097ba94d97851cf502b7b2e063) by @stonith404)
- automatically create parent directory of Sqlite db ([cfc9e46](https://github.com/pocket-id/pocket-id/commit/cfc9e464d983b051e7ed4da1620fae61dc73cff2) by @stonith404)
- global audit log user filter not working ([d98c0a3](https://github.com/pocket-id/pocket-id/commit/d98c0a391a747f9eea70ea01c3f984264a4a7a19) by @stonith404)
- theme mode not correctly applied if selected manually ([a1cd325](https://github.com/pocket-id/pocket-id/commit/a1cd3251cd2b7d7aca610696ef338c5d01fdce2e) by @stonith404)
- hide theme switcher on auth pages because of dynamic background ([5d6a7fd](https://github.com/pocket-id/pocket-id/commit/5d6a7fdb58b6b82894dcb9be3b9fe6ca3e53f5fa) by @stonith404)
### Documentation
- add `ENCRYPTION_KEY` to `.env.example` for breaking change preparation ([4eeb06f](https://github.com/pocket-id/pocket-id/commit/4eeb06f29d984164939bf66299075efead87ee19) by @stonith404)
### Features
- light/dark/system mode switcher ([#1081](https://github.com/pocket-id/pocket-id/pull/1081) by @kmendell)
- add support for S3 storage backend ([#1080](https://github.com/pocket-id/pocket-id/pull/1080) by @stonith404)
- add support for WEBP profile pictures ([#1090](https://github.com/pocket-id/pocket-id/pull/1090) by @stonith404)
- add database storage backend ([#1091](https://github.com/pocket-id/pocket-id/pull/1091) by @ItalyPaleAle)
- adding/removing passkeys creates an entry in audit logs ([#1099](https://github.com/pocket-id/pocket-id/pull/1099) by @ItalyPaleAle)
- add option to disable S3 integrity check ([a3c9687](https://github.com/pocket-id/pocket-id/commit/a3c968758a17e95b2e55ae179d6601d8ec2cf052) by @stonith404)
- add `Cache-Control: private, no-store` to all API routes per default ([#1126](https://github.com/pocket-id/pocket-id/pull/1126) by @stonith404)
### Other
- update pnpm to 10.20 ([#1082](https://github.com/pocket-id/pocket-id/pull/1082) by @kmendell)
- run checks on PR to `breaking/**` branches ([ab9c0f9](https://github.com/pocket-id/pocket-id/commit/ab9c0f9ac092725c70ec3a963f57bc739f425d4f) by @stonith404)
- use constants for AppEnv values ([#1098](https://github.com/pocket-id/pocket-id/pull/1098) by @ItalyPaleAle)
- bump golang.org/x/crypto from 0.43.0 to 0.45.0 in /backend in the go_modules group across 1 directory ([#1107](https://github.com/pocket-id/pocket-id/pull/1107) by @dependabot[bot])
- add Finish files ([ca888b3](https://github.com/pocket-id/pocket-id/commit/ca888b3dd221a209df5e7beb749156f7ea21e1c0) by @stonith404)
- upgrade dependencies ([4bde271](https://github.com/pocket-id/pocket-id/commit/4bde271b4715f59bd2ed1f7c18a867daf0f26b8b) by @stonith404)
- fix Dutch validation message ([f523f39](https://github.com/pocket-id/pocket-id/commit/f523f39483a06256892d17dc02528ea009c87a9f) by @stonith404)
- fix package vulnerabilities ([3d46bad](https://github.com/pocket-id/pocket-id/commit/3d46badb3cecc1ee8eb8bfc9b377108be32d4ffc) by @stonith404)
- update vscode launch.json ([#1117](https://github.com/pocket-id/pocket-id/pull/1117) by @mnestor)
- rename file backend value `fs` to `filesystem` ([8d30346](https://github.com/pocket-id/pocket-id/commit/8d30346f642b483653f7a3dec006cb0273927afb) by @stonith404)
- fix wrong storage value ([b2c718d](https://github.com/pocket-id/pocket-id/commit/b2c718d13d12b6c152e19974d3490c2ed7f5d51d) by @stonith404)
- run formatter ([14c7471](https://github.com/pocket-id/pocket-id/commit/14c7471b5272cdaf42751701d842348d0d60cd0e) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v1.15.0...v1.16.0
## v1.15.0
### Bug Fixes

View File

@@ -4,7 +4,7 @@ Pocket ID is a simple OIDC provider that allows users to authenticate with their
→ Try out the [Demo](https://demo.pocket-id.org)
<img src="https://github.com/user-attachments/assets/1e99ba44-76da-4b47-9b8a-dbe9b7f84512" width="1200"/>
<img src="https://github.com/user-attachments/assets/96ac549d-b897-404a-8811-f42b16ea58e2" width="1200"/>
The goal of Pocket ID is to be a simple and easy-to-use. There are other self-hosted OIDC providers like [Keycloak](https://www.keycloak.org/) or [ORY Hydra](https://www.ory.sh/hydra/) but they are often too complex for simple use cases.

View File

@@ -1,12 +1,9 @@
package main
import (
"fmt"
"os"
_ "time/tzdata"
"github.com/pocket-id/pocket-id/backend/internal/cmds"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
// @title Pocket ID API
@@ -14,9 +11,5 @@ import (
// @description.markdown
func main() {
if err := common.ValidateEnvConfig(&common.EnvConfig); err != nil {
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
os.Exit(1)
}
cmds.Execute()
}

View File

@@ -3,107 +3,107 @@ module github.com/pocket-id/pocket-id/backend
go 1.25
require (
github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/aws/smithy-go v1.24.0
github.com/aws/aws-sdk-go-v2 v1.39.6
github.com/aws/aws-sdk-go-v2/config v1.31.17
github.com/aws/aws-sdk-go-v2/credentials v1.18.21
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0
github.com/aws/smithy-go v1.23.2
github.com/caarlos0/env/v11 v11.3.1
github.com/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-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/gin-contrib/slog v1.2.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-contrib/slog v1.1.0
github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.19.0
github.com/go-co-op/gocron/v2 v2.17.0
github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0
github.com/golang-migrate/migrate/v4 v4.19.1
github.com/go-playground/validator/v10 v10.28.0
github.com/go-webauthn/webauthn v0.14.0
github.com/golang-migrate/migrate/v4 v4.19.0
github.com/google/uuid v1.6.0
github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.3
github.com/lestrrat-go/httprc/v3 v3.0.1
github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.1.1
github.com/spf13/cobra v1.10.2
github.com/oschwald/maxminddb-golang/v2 v2.0.0
github.com/spf13/cobra v1.10.1
github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/log v0.15.0
go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/log v0.15.0
go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
golang.org/x/crypto v0.46.0
golang.org/x/image v0.34.0
golang.org/x/sync v0.19.0
golang.org/x/text v0.32.0
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0
go.opentelemetry.io/otel v1.38.0
go.opentelemetry.io/otel/log v0.14.0
go.opentelemetry.io/otel/metric v1.38.0
go.opentelemetry.io/otel/sdk v1.38.0
go.opentelemetry.io/otel/sdk/log v0.14.0
go.opentelemetry.io/otel/sdk/metric v1.38.0
go.opentelemetry.io/otel/trace v1.38.0
golang.org/x/crypto v0.43.0
golang.org/x/image v0.32.0
golang.org/x/sync v0.17.0
golang.org/x/text v0.30.0
golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
gorm.io/gorm v1.31.0
)
require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/bytedance/sonic v1.14.1 // indirect
github.com/bytedance/sonic/loader v0.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // 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.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/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-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.1.27 // indirect
github.com/go-webauthn/x v0.1.25 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.1 // indirect
github.com/goccy/go-yaml v1.18.0 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.6 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // 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.8.0 // indirect
github.com/jackc/pgx/v5 v5.7.6 // 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
@@ -115,57 +115,61 @@ require (
github.com/lestrrat-go/dsig v1.0.0 // indirect
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/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-sqlite3 v1.14.33 // indirect
github.com/mattn/go-sqlite3 v1.14.32 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/common v0.67.1 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.58.0 // indirect
github.com/prometheus/procfs v0.18.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.55.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.7 // 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.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 // indirect
go.opentelemetry.io/proto/otlp v1.8.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/oauth2 v0.34.0 // indirect
golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.46.0 // indirect
golang.org/x/oauth2 v0.32.0 // indirect
golang.org/x/sys v0.37.0 // indirect
golang.org/x/tools v0.38.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 // indirect
google.golang.org/grpc v1.76.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.67.4 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.42.2 // indirect
modernc.org/sqlite v1.39.1 // indirect
)

View File

@@ -1,57 +1,55 @@
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
github.com/aws/aws-sdk-go-v2 v1.39.6 h1:2JrPCVgWJm7bm83BDwY5z8ietmeJUbh3O2ACnn+Xsqk=
github.com/aws/aws-sdk-go-v2 v1.39.6/go.mod h1:c9pm7VwuW0UPxAEYGyTmyurVcNrbF6Rt/wixFqDhcjE=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 h1:DHctwEM8P8iTXFxC/QK0MRjwEpWQeM9yzidCRjldUz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3/go.mod h1:xdCzcZEtnSTKVDOmUZs4l/j3pSV6rpo1WXl5ugNsL8Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17 h1:QFl8lL6RgakNK86vusim14P2k8BFSxjvUkcWLDjgz9Y=
github.com/aws/aws-sdk-go-v2/config v1.31.17/go.mod h1:V8P7ILjp/Uef/aX8TjGk6OHZN6IKPM5YW6S78QnRD5c=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 h1:x2Ibm/Af8Fi+BH+Hsn9TXGdT+hKbDd5XOTZxTMxDk7o=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3/go.mod h1:IW1jwyrQgMdhisceG8fQLmQIydcT/jWY21rFhzgaKwo=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 h1:NvMjwvv8hpGUILarKw7Z4Q0w1H9anXKsesMxtw++MA4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4/go.mod h1:455WPHSwaGj2waRSpQp7TsnpOnBfw8iDfPfbwl7KPJE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
github.com/aws/smithy-go v1.23.2 h1:Crv0eatJUQhaManss33hS5r40CG3ZFH+21XSkqMrIUM=
github.com/aws/smithy-go v1.23.2/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/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/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -66,9 +64,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/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.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
@@ -98,10 +95,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
@@ -112,8 +109,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.19.0 h1:OKf2y6LXPs/BgBI2fl8PxUpNAI1DA9Mg+hSeGOS38OU=
github.com/go-co-op/gocron/v2 v2.19.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
github.com/go-co-op/gocron/v2 v2.17.0 h1:e/oj6fcAM8vOOKZxv2Cgfmjo+s8AXC46po5ZPtaSea4=
github.com/go-co-op/gocron/v2 v2.17.0/go.mod h1:Zii6he+Zfgy5W9B+JKk/KwejFOW0kZTFvHtwIpR4aBI=
github.com/go-ldap/ldap/v3 v3.4.12 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -127,59 +124,58 @@ 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.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.1.27 h1:CLyuB8JGn9xvw0etBl4fnclcbPTwhKpN4Xg32zaSYnI=
github.com/go-webauthn/x v0.1.27/go.mod h1:KGYJQAPPgbpDKi4N7zKMGL+Iz6WgxKg3OlhVbPtuJXI=
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
github.com/go-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang-migrate/migrate/v4 v4.19.0 h1:RcjOnCGz3Or6HQYEJ/EEVLfWnmw9KnoigPSjzhCuaSE=
github.com/golang-migrate/migrate/v4 v4.19.0/go.mod h1:9dyEcu+hO+G9hPSw8AIg50yg622pXJsoHItQnDGZkI0=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.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-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-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm v0.9.6 h1:Ku42PT4LmjDu1H5C5ISWLlpI1mj+Zq7sPGKoRw2XROA=
github.com/google/go-tpm v0.9.6/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/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/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
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.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
@@ -226,10 +222,12 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.3 h1:WjLHWkDkgWXeIUrKi/7lS/sGq2DjkSAwdTbH5RHXAKs=
github.com/lestrrat-go/httprc/v3 v3.0.3/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
github.com/lestrrat-go/httprc/v3 v3.0.1 h1:3n7Es68YYGZb2Jf+k//llA4FTZMl3yCwIjFIk4ubevI=
github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk=
github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNBEYU=
github.com/lestrrat-go/option v1.0.1/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=
@@ -238,10 +236,12 @@ 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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
@@ -263,29 +263,28 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
github.com/oschwald/maxminddb-golang/v2 v2.0.0 h1:Gyljxck1kHbBxDgLM++NfDWBqvu1pWWfT8XbosSo0bo=
github.com/oschwald/maxminddb-golang/v2 v2.0.0/go.mod h1:gG4V88LsawPEqtbL1Veh1WRh+nVSYwXzJ1P5Fcn77g0=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/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=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/prometheus/procfs v0.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
github.com/prometheus/procfs v0.18.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -295,143 +294,141 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.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.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.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.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
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=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 h1:7TYhBCu6Xz6vDJGNtEslWZLuuX2IJ/aH50hBY4MVeUg=
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0/go.mod h1:tHQctZfAe7e4PBPGyt3kae6mQFXNpj+iiDJa3ithM50=
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0 h1:9pzPj3RFyKOxBAMkM2w84LpT+rdHam1XoFA+QhARiRw=
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0/go.mod h1:hlVZx1btWH0XTfXpuGX9dsquB50s+tc3fYFOO5elo2M=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0 h1:7IKZbAYwlwLXAdu7SVPhzTjDjogWZxP4MIa7rovY+PU=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0/go.mod h1:+TF5nf3NIv2X8PGxqfYOaRnAoMM43rUA2C3XsN2DoWA=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0=
go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 h1:0BSddrtQqLEylcErkeFrJBmwFzcqfQq9+/uxfTZq+HE=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0/go.mod h1:87sjYuAPzaRCtdd09GU5gM1U9wQLrrcYrm77mh5EBoc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g=
go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0 h1:bwnLpizECbPr1RrQ27waeY2SPIPeccCx/xLuoYADZ9s=
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0/go.mod h1:3nWlOiiqA9UtUnrcNk82mYasNxD8ehOspL0gOfEo6Y4=
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0 h1:/Rij/t18Y7rUayNg7Id6rPrEnHgorxYabm2E6wUdPP4=
go.opentelemetry.io/contrib/bridges/prometheus v0.63.0/go.mod h1:AdyDPn6pkbkt2w01n3BubRVk7xAsCRq1Yg1mpfyA/0E=
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0 h1:NLnZybb9KkfMXPwZhd5diBYJoVxiO9Qa06dacEA7ySY=
go.opentelemetry.io/contrib/exporters/autoexport v0.63.0/go.mod h1:OvRg7gm5WRSCtxzGSsrFHbDLToYlStHNZQ+iPNIyD6g=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0 h1:5kSIJ0y8ckZZKoDhZHdVtcyjVi6rXyAwyaR8mp4zLbg=
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.63.0/go.mod h1:i+fIMHvcSQtsIY82/xgiVWRklrNt/O6QriHLjzGeY+s=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0 h1:uHsCCOSKl0kLrV2dLkFK+8Ywk9iKa/fptkytc6aFFEo=
go.opentelemetry.io/contrib/propagators/b3 v1.38.0/go.mod h1:wMRSZJZcY8ya9mApLLhwIMjqmApy2o/Ml+62lhvxyHU=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0 h1:OMqPldHt79PqWKOMYIAQs3CxAi7RLgPxwfFSwr4ZxtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.14.0/go.mod h1:1biG4qiqTxKiUCtoWDPpL3fB3KxVwCiGw81j3nKMuHE=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0 h1:QQqYw3lkrzwVsoEX0w//EhH/TCnpRdEenKBOOEIMjWc=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.14.0/go.mod h1:gSVQcr17jk2ig4jqJ2DX30IdWH251JcNAecvrqTxH1s=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0 h1:vl9obrcoWVKp/lwl8tRE33853I8Xru9HFbw/skNeLs8=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.38.0/go.mod h1:GAXRxmLJcVM3u22IjTg74zWBrRCKq8BnOqUVLodpcpw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0 h1:Oe2z/BCg5q7k4iXC3cqJxKYg0ieRiOqF0cecFYdPTwk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.38.0/go.mod h1:ZQM5lAJpOsKnYagGg/zV2krVqTtaVdYdDkhMoX6Oalg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0 h1:lwI4Dc5leUqENgGuQImwLo4WnuXFPetmPpkLi2IrX54=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.38.0/go.mod h1:Kz/oCE7z5wuyhPxsXDuaPteSWqjSBD5YaSdbxZYGbGk=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0 h1:aTL7F04bJHUlztTsNGJ2l+6he8c+y/b//eR0jjjemT4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.38.0/go.mod h1:kldtb7jDTeol0l3ewcmd8SDvx3EmIE7lyvqbasU3QC4=
go.opentelemetry.io/otel/exporters/prometheus v0.60.0 h1:cGtQxGvZbnrWdC2GyjZi0PDKVSLWP/Jocix3QWfXtbo=
go.opentelemetry.io/otel/exporters/prometheus v0.60.0/go.mod h1:hkd1EekxNo69PTV4OWFGZcKQiIqg0RfuWExcPKFvepk=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0 h1:B/g+qde6Mkzxbry5ZZag0l7QrQBCtVm7lVjaLgmpje8=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.14.0/go.mod h1:mOJK8eMmgW6ocDJn6Bn11CcZ05gi3P8GylBXEkZtbgA=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0 h1:wm/Q0GAAykXv83wzcKzGGqAnnfLFyFe7RslekZuv+VI=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.38.0/go.mod h1:ra3Pa40+oKjvYh+ZD3EdxFZZB0xdMfuileHAm4nNN7w=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0 h1:kJxSDN4SgWWTjG/hPp3O7LCGLcHXFlvS2/FFOrwL+SE=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.38.0/go.mod h1:mgIOzS7iZeKJdeB8/NYHrJ48fdGc71Llo5bJ1J4DWUE=
go.opentelemetry.io/otel/log v0.14.0 h1:2rzJ+pOAZ8qmZ3DDHg73NEKzSZkhkGIua9gXtxNGgrM=
go.opentelemetry.io/otel/log v0.14.0/go.mod h1:5jRG92fEAgx0SU/vFPxmJvhIuDU9E1SUnEQrMlJpOno=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/log v0.14.0 h1:JU/U3O7N6fsAXj0+CXz21Czg532dW2V4gG1HE/e8Zrg=
go.opentelemetry.io/otel/sdk/log v0.14.0/go.mod h1:imQvII+0ZylXfKU7/wtOND8Hn4OpT3YUoIgqJVksUkM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/image v0.32.0 h1:6lZQWq75h7L5IWNk0r+SCpUJ6tUVd3v4ZHnbRKLkUDQ=
golang.org/x/image v0.32.0/go.mod h1:/R37rrQmKXtO6tYXAjtDLwQgFLHmhW+V6ayXlxzP2Pc=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8 h1:mepRgnBZa07I4TRuomDE4sTIYieg/osKmzIf4USdWS4=
google.golang.org/genproto/googleapis/api v0.0.0-20251022142026-3a174f9686a8/go.mod h1:fDMmzKV90WSg1NbozdqrE64fkuTv6mlq2zxo9ad+3yo=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
@@ -440,22 +437,20 @@ 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.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -464,8 +459,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -24,8 +24,7 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
"background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
}
sourceFiles, err := resources.FS.ReadDir("images")

View File

@@ -7,7 +7,6 @@ import (
"time"
_ "github.com/golang-migrate/migrate/v4/source/file"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/job"
@@ -16,16 +15,6 @@ import (
)
func Bootstrap(ctx context.Context) error {
var shutdownFns []utils.Service
defer func() { //nolint:contextcheck
// Invoke all shutdown functions on exit
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := utils.NewServiceRunner(shutdownFns...).Run(shutdownCtx); err != nil {
slog.Error("Error during graceful shutdown", "error", err)
}
}()
// 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 {
@@ -33,14 +22,28 @@ func Bootstrap(ctx context.Context) error {
}
slog.InfoContext(ctx, "Pocket ID is starting")
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize the file storage backend
var fileStorage storage.FileStorage
fileStorage, err := InitStorage(ctx, db)
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
case storage.TypeS3:
s3Cfg := storage.S3Config{
Bucket: common.EnvConfig.S3Bucket,
Region: common.EnvConfig.S3Region,
Endpoint: common.EnvConfig.S3Endpoint,
AccessKeyID: common.EnvConfig.S3AccessKeyID,
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
Root: common.EnvConfig.UploadPath,
}
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
default:
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
}
if err != nil {
return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err)
return fmt.Errorf("failed to initialize file storage: %w", err)
}
imageExtensions, err := initApplicationImages(ctx, fileStorage)
@@ -48,32 +51,18 @@ func Bootstrap(ctx context.Context) error {
return fmt.Errorf("failed to initialize application images: %w", err)
}
// Connect to the database
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Create all services
svc, err := initServices(ctx, db, httpClient, imageExtensions, fileStorage)
if err != nil {
return fmt.Errorf("failed to initialize services: %w", err)
}
waitUntil, err := svc.appLockService.Acquire(ctx, false)
if err != nil {
return fmt.Errorf("failed to acquire application lock: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Until(waitUntil)):
}
shutdownFn := func(shutdownCtx context.Context) error {
sErr := svc.appLockService.Release(shutdownCtx)
if sErr != nil {
return fmt.Errorf("failed to release application lock: %w", sErr)
}
return nil
}
shutdownFns = append(shutdownFns, shutdownFn)
// Init the job scheduler
scheduler, err := job.NewScheduler()
if err != nil {
@@ -85,51 +74,28 @@ func Bootstrap(ctx context.Context) error {
}
// Init the router
router, err := initRouter(db, svc)
if err != nil {
return fmt.Errorf("failed to initialize router: %w", err)
}
router := initRouter(db, svc)
// Run all background services
// This call blocks until the context is canceled
services := []utils.Service{svc.appLockService.RunRenewal, router}
if common.EnvConfig.AppEnv != "test" {
services = append(services, scheduler.Run)
}
err = utils.NewServiceRunner(services...).Run(ctx)
err = utils.
NewServiceRunner(router, scheduler.Run).
Run(ctx)
if err != nil {
return fmt.Errorf("failed to run services: %w", err)
}
// 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) //nolint:contextcheck
if err != nil {
slog.Error("Error shutting down services", slog.Any("error", err))
}
return nil
}
func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) {
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
case storage.TypeDatabase:
fileStorage, err = storage.NewDatabaseStorage(db)
case storage.TypeS3:
s3Cfg := storage.S3Config{
Bucket: common.EnvConfig.S3Bucket,
Region: common.EnvConfig.S3Region,
Endpoint: common.EnvConfig.S3Endpoint,
AccessKeyID: common.EnvConfig.S3AccessKeyID,
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
DisableDefaultIntegrityChecks: common.EnvConfig.S3DisableDefaultIntegrityChecks,
Root: common.EnvConfig.UploadPath,
}
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
default:
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
}
if err != nil {
return fileStorage, err
}
return fileStorage, nil
}

View File

@@ -12,7 +12,12 @@ import (
"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"
slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres"
"gorm.io/gorm"
@@ -21,10 +26,11 @@ import (
"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, err error) {
db, err = ConnectDatabase()
db, err = connectDatabase()
if err != nil {
return nil, fmt.Errorf("failed to connect to database: %w", err)
}
@@ -33,15 +39,105 @@ func NewDatabase() (db *gorm.DB, err error) {
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
}
// Choose the correct driver for the database provider
var driver database.Driver
switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
NoTxWrap: true,
})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
return nil, fmt.Errorf("failed to create migration driver: %w", err)
}
// Run migrations
if err := utils.MigrateDatabase(sqlDb); err != nil {
if err := migrateDatabase(driver); err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err)
}
return db, nil
}
func ConnectDatabase() (db *gorm.DB, err error) {
func migrateDatabase(driver database.Driver) error {
// 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)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create migration instance: %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
// Choose the correct database provider
@@ -59,12 +155,6 @@ func ConnectDatabase() (db *gorm.DB, err error) {
return nil, err
}
if !isMemoryDB {
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
return nil, err
}
}
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
err = ensureSqliteTempDir(filepath.Dir(dbPath))
if err != nil {
@@ -298,27 +388,6 @@ func isSqliteInMemory(connString string) bool {
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
}
// ensureSqliteDatabaseDir creates the parent directory for the SQLite database file if it doesn't exist yet
func ensureSqliteDatabaseDir(dbPath string) error {
dir := filepath.Dir(dbPath)
info, err := os.Stat(dir)
switch {
case err == nil:
if !info.IsDir() {
return fmt.Errorf("SQLite database directory '%s' is not a directory", dir)
}
return nil
case os.IsNotExist(err):
if err := os.MkdirAll(dir, 0700); err != nil {
return fmt.Errorf("failed to create SQLite database directory '%s': %w", dir, err)
}
return nil
default:
return fmt.Errorf("failed to check SQLite database directory '%s': %w", dir, err)
}
}
// 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

View File

@@ -2,8 +2,6 @@ package bootstrap
import (
"net/url"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -86,29 +84,6 @@ func TestIsSqliteInMemory(t *testing.T) {
}
}
func TestEnsureSqliteDatabaseDir(t *testing.T) {
t.Run("creates missing directory", func(t *testing.T) {
tempDir := t.TempDir()
dbPath := filepath.Join(tempDir, "nested", "pocket-id.db")
err := ensureSqliteDatabaseDir(dbPath)
require.NoError(t, err)
info, err := os.Stat(filepath.Dir(dbPath))
require.NoError(t, err)
assert.True(t, info.IsDir())
})
t.Run("fails when parent is file", func(t *testing.T) {
tempDir := t.TempDir()
filePath := filepath.Join(tempDir, "file.txt")
require.NoError(t, os.WriteFile(filePath, []byte("test"), 0o600))
err := ensureSqliteDatabaseDir(filepath.Join(filePath, "data.db"))
require.Error(t, err)
})
}
func TestConvertSqlitePragmaArgs(t *testing.T) {
tests := []struct {
name string

View File

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

View File

@@ -29,14 +29,23 @@ import (
// This is used to register additional controllers for tests
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
func initRouter(db *gorm.DB, svc *services) utils.Service {
runner, err := initRouterInternal(db, svc)
if err != nil {
slog.Error("Failed to init router", "error", err)
os.Exit(1)
}
return runner
}
func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Set the appropriate Gin mode based on the environment
switch common.EnvConfig.AppEnv {
case common.AppEnvProduction:
case "production":
gin.SetMode(gin.ReleaseMode)
case common.AppEnvDevelopment:
case "development":
gin.SetMode(gin.DebugMode)
case common.AppEnvTest:
case "test":
gin.SetMode(gin.TestMode)
}
@@ -54,8 +63,6 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware
r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add())
r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add())
@@ -83,10 +90,9 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
// Add test controller in non-production environments
if !common.EnvConfig.AppEnv.IsProduction() {
if common.EnvConfig.AppEnv != "production" {
for _, f := range registerTestControllers {
f(apiGroup, db, svc)
}
@@ -104,17 +110,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
srv := &http.Server{
MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second,
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
// HEAD requests don't get matched by Gin routes, so we convert them to GET
// middleware.HeadMiddleware will convert them back to HEAD later
if req.Method == http.MethodHead {
req.Method = http.MethodGet
ctx := context.WithValue(req.Context(), middleware.IsHeadRequestCtxKey{}, true)
req = req.WithContext(ctx)
}
r.ServeHTTP(w, req)
}),
Handler: r,
}
// Set up the listener
@@ -190,7 +186,6 @@ func initLogger(r *gin.Engine) {
"GET /api/application-images/logo",
"GET /api/application-images/background",
"GET /api/application-images/favicon",
"GET /api/application-images/email",
"GET /_app",
"GET /fonts",
"GET /healthz",

View File

@@ -12,24 +12,21 @@ import (
)
type services struct {
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
scimService *service.ScimService
scimSchedulerService *service.ScimSchedulerService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
appLockService *service.AppLockService
appConfigService *service.AppConfigService
appImagesService *service.AppImagesService
emailService *service.EmailService
geoLiteService *service.GeoLiteService
auditLogService *service.AuditLogService
jwtService *service.JwtService
webauthnService *service.WebAuthnService
userService *service.UserService
customClaimService *service.CustomClaimService
oidcService *service.OidcService
userGroupService *service.UserGroupService
ldapService *service.LdapService
apiKeyService *service.ApiKeyService
versionService *service.VersionService
fileStorage storage.FileStorage
}
// Initializes all services
@@ -43,7 +40,6 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.fileStorage = fileStorage
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
svc.appLockService = service.NewAppLockService(db)
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
if err != nil {
@@ -70,13 +66,8 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.scimService = service.NewScimService(db, httpClient)
svc.scimSchedulerService, err = service.NewScimSchedulerService(ctx, svc.scimService)
if err != nil {
return nil, fmt.Errorf("failed to create SCIM scheduler service: %w", err)
}
svc.versionService = service.NewVersionService(httpClient)

View File

@@ -1,70 +0,0 @@
package cmds
import (
"context"
"fmt"
"io"
"os"
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/spf13/cobra"
)
type exportFlags struct {
Path string
}
func init() {
var flags exportFlags
exportCmd := &cobra.Command{
Use: "export",
Short: "Exports all data of Pocket ID into a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runExport(cmd.Context(), flags)
},
}
exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout")
rootCmd.AddCommand(exportCmd)
}
// runExport orchestrates the export flow
func runExport(ctx context.Context, flags exportFlags) error {
db, err := bootstrap.NewDatabase()
if err != nil {
return fmt.Errorf("failed to connect to database: %w", err)
}
storage, err := bootstrap.InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
exportService := service.NewExportService(db, storage)
var w io.Writer
if flags.Path == "-" {
w = os.Stdout
} else {
file, err := os.Create(flags.Path)
if err != nil {
return fmt.Errorf("failed to create export file: %w", err)
}
defer file.Close()
w = file
}
if err := exportService.ExportToZip(ctx, w); err != nil {
return fmt.Errorf("failed to export data: %w", err)
}
if flags.Path != "-" {
fmt.Printf("Exported data to %s\n", flags.Path)
}
return nil
}

View File

@@ -1,191 +0,0 @@
package cmds
import (
"archive/zip"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"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/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
type importFlags struct {
Path string
Yes bool
ForcefullyAcquireLock bool
}
func init() {
var flags importFlags
importCmd := &cobra.Command{
Use: "import",
Short: "Imports all data of Pocket ID from a ZIP file",
RunE: func(cmd *cobra.Command, args []string) error {
return runImport(cmd.Context(), flags)
},
}
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance")
rootCmd.AddCommand(importCmd)
}
// runImport handles the high-level orchestration of the import process
func runImport(ctx context.Context, flags importFlags) error {
if !flags.Yes {
ok, err := askForConfirmation()
if err != nil {
return fmt.Errorf("failed to get confirmation: %w", err)
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
}
}
var (
zipReader *zip.ReadCloser
cleanup func()
err error
)
if flags.Path == "-" {
zipReader, cleanup, err = readZipFromStdin()
defer cleanup()
} else {
zipReader, err = zip.OpenReader(flags.Path)
}
if err != nil {
return fmt.Errorf("failed to open zip: %w", err)
}
defer zipReader.Close()
db, err := bootstrap.ConnectDatabase()
if err != nil {
return err
}
err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock)
if err != nil {
return err
}
storage, err := bootstrap.InitStorage(ctx, db)
if err != nil {
return fmt.Errorf("failed to initialize storage: %w", err)
}
importService := service.NewImportService(db, storage)
err = importService.ImportFromZip(ctx, &zipReader.Reader)
if err != nil {
return fmt.Errorf("failed to import data from zip: %w", err)
}
fmt.Println("Import completed successfully.")
return nil
}
func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
// Check if the kv table exists, in case we are starting from an empty database
exists, err := utils.DBTableExists(db, "kv")
if err != nil {
return fmt.Errorf("failed to check if kv table exists: %w", err)
}
if !exists {
// This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks
// In either case, there's no lock to acquire
fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.")
return nil
}
// Note that we do not call a deferred Release if the data was imported
// This is because we are overriding the contents of the database, so the lock is automatically lost
appLockService := service.NewAppLockService(db)
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
waitUntil, err := appLockService.Acquire(opCtx, force)
if err != nil {
if errors.Is(err, service.ErrLockUnavailable) {
//nolint:staticcheck
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
}
return fmt.Errorf("failed to acquire application lock: %w", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Until(waitUntil)):
}
return nil
}
func askForConfirmation() (bool, error) {
fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.")
fmt.Println()
fmt.Println("WARNING: Import will erase all existing data at the following locations:")
fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString))
fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath))
ok, err := utils.PromptForConfirmation("Do you want to continue?")
if err != nil {
return false, err
}
return ok, nil
}
// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails
func absolutePathOrOriginal(path string) string {
abs, err := filepath.Abs(path)
if err != nil {
return path
}
return abs
}
func readZipFromStdin() (*zip.ReadCloser, func(), error) {
tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip")
if err != nil {
return nil, nil, fmt.Errorf("failed to create temporary file: %w", err)
}
cleanup := func() {
_ = os.Remove(tmpFile.Name())
}
if _, err := io.Copy(tmpFile, os.Stdin); err != nil {
tmpFile.Close()
cleanup()
return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err)
}
if err := tmpFile.Close(); err != nil {
cleanup()
return nil, nil, fmt.Errorf("failed to close temporary file: %w", err)
}
r, err := zip.OpenReader(tmpFile.Name())
if err != nil {
cleanup()
return nil, nil, err
}
return r, cleanup, nil
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/lestrrat-go/jwx/v3/jwa"
@@ -79,7 +78,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
}
if !ok {
fmt.Println("Aborted")
os.Exit(1)
return nil
}
}

View File

@@ -1,6 +1,8 @@
package cmds
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@@ -67,14 +69,78 @@ func TestKeyRotate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
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"),
}

View File

@@ -51,7 +51,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{
}
// Create a new access token that expires in 1 hour
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour, false)
oneTimeAccessToken, txErr = service.NewOneTimeAccessToken(user.ID, time.Hour)
if txErr != nil {
return fmt.Errorf("failed to generate access token: %w", txErr)
}

View File

@@ -12,10 +12,9 @@ import (
)
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.",
SilenceUsage: true,
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())

View File

@@ -15,7 +15,6 @@ import (
_ "github.com/joho/godotenv/autoload"
)
type AppEnv string
type DbProvider string
const (
@@ -26,9 +25,6 @@ const (
)
const (
AppEnvProduction AppEnv = "production"
AppEnvDevelopment AppEnv = "development"
AppEnvTest AppEnv = "test"
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"
@@ -38,42 +34,38 @@ const (
)
type EnvConfigSchema struct {
AppEnv AppEnv `env:"APP_ENV" options:"toLower"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy bool `env:"TRUST_PROXY"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"`
LogJSON bool `env:"LOG_JSON"`
AppEnv string `env:"APP_ENV" options:"toLower"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"`
S3Bucket string `env:"S3_BUCKET"`
S3Region string `env:"S3_REGION"`
S3Endpoint string `env:"S3_ENDPOINT"`
S3AccessKeyID string `env:"S3_ACCESS_KEY_ID"`
S3SecretAccessKey string `env:"S3_SECRET_ACCESS_KEY"`
S3ForcePathStyle bool `env:"S3_FORCE_PATH_STYLE"`
KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"`
Host string `env:"HOST" options:"toLower"`
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"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
}
var EnvConfig = defaultConfig()
@@ -88,16 +80,16 @@ func init() {
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: AppEnvProduction,
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
AuditLogRetentionDays: 90,
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
AppEnv: "production",
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "fs",
KeysPath: "data/keys",
AppURL: AppUrl,
Port: "1411",
Host: "0.0.0.0",
GeoLiteDBPath: "data/GeoLite2-City.mmdb",
GeoLiteDBUrl: MaxMindGeoLiteCityUrl,
}
}
@@ -120,28 +112,32 @@ func parseEnvConfig() error {
return fmt.Errorf("error preparing env config: %w", err)
}
err = validateEnvConfig(&EnvConfig)
if err != nil {
return err
}
return nil
}
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error {
// validateEnvConfig checks the EnvConfig for required fields and valid values
func validateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
if len(config.EncryptionKey) < 16 {
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
}
switch {
case config.DbConnectionString == "":
config.DbProvider = DbProviderSqlite
config.DbConnectionString = defaultSqliteConnString
case strings.HasPrefix(config.DbConnectionString, "postgres://") || strings.HasPrefix(config.DbConnectionString, "postgresql://"):
config.DbProvider = DbProviderPostgres
switch config.DbProvider {
case DbProviderSqlite:
if config.DbConnectionString == "" {
config.DbConnectionString = defaultSqliteConnString
}
case DbProviderPostgres:
if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
}
default:
config.DbProvider = DbProviderSqlite
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
}
parsedAppUrl, err := url.Parse(config.AppURL)
@@ -165,15 +161,31 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
}
}
switch config.FileBackend {
case "s3", "database":
switch config.KeysStorage {
// KeysStorage defaults to "file" if empty
case "":
config.KeysStorage = "file"
case "database":
if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
}
case "file":
// All good, these are valid values
case "", "filesystem":
default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
}
switch config.FileBackend {
case "s3":
if config.KeysStorage == "file" {
return errors.New("KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
}
case "", "fs":
if config.UploadPath == "" {
config.UploadPath = defaultFsUploadPath
}
default:
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
return errors.New("invalid FILE_BACKEND value. Must be 'fs' or 's3'")
}
// Validate LOCAL_IPV6_RANGES
@@ -195,10 +207,6 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
}
if config.AuditLogRetentionDays <= 0 {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
}
return nil
}
@@ -278,11 +286,3 @@ func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructFi
return nil
}
func (a AppEnv) IsProduction() bool {
return a == AppEnvProduction
}
func (a AppEnv) IsTest() bool {
return a == AppEnvTest
}

View File

@@ -8,20 +8,6 @@ import (
"github.com/stretchr/testify/require"
)
func parseAndValidateEnvConfig(t *testing.T) error {
t.Helper()
if _, exists := os.LookupEnv("ENCRYPTION_KEY"); !exists {
t.Setenv("ENCRYPTION_KEY", "0123456789abcdef")
}
if err := parseEnvConfig(); err != nil {
return err
}
return ValidateEnvConfig(&EnvConfig)
}
func TestParseEnvConfig(t *testing.T) {
// Store original config to restore later
originalConfig := EnvConfig
@@ -31,10 +17,11 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
@@ -42,76 +29,147 @@ func TestParseEnvConfig(t *testing.T) {
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 := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
})
t.Run("should fail when ENCRYPTION_KEY is too short", func(t *testing.T) {
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("DB_PROVIDER", "invalid")
t.Setenv("DB_CONNECTION_STRING", "test")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("ENCRYPTION_KEY", "short")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be at least 16 bytes long")
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("APP_URL", "http://localhost:3000")
err := parseAndValidateEnvConfig(t)
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 := parseAndValidateEnvConfig(t)
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 := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "APP_URL must not contain a path")
})
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
})
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
})
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "file", EnvConfig.KeysStorage)
})
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "database")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
})
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
validStorageTypes := []string{"file", "database"}
for _, storage := range validStorageTypes {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", storage)
if storage == "database" {
t.Setenv("ENCRYPTION_KEY", "test-key")
}
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, storage, EnvConfig.KeysStorage)
}
})
t.Run("should fail with invalid KEYS_STORAGE value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("KEYS_STORAGE", "invalid")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
})
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("UI_CONFIG_DISABLED", "true")
@@ -120,7 +178,7 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("TRUST_PROXY", "true")
t.Setenv("ANALYTICS_DISABLED", "false")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.NoError(t, err)
assert.True(t, EnvConfig.UiConfigDisabled)
assert.True(t, EnvConfig.MetricsEnabled)
@@ -129,56 +187,23 @@ func TestParseEnvConfig(t *testing.T) {
assert.False(t, EnvConfig.AnalyticsDisabled)
})
t.Run("should default audit log retention days to 90", 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, 90, EnvConfig.AuditLogRetentionDays)
})
t.Run("should parse audit log retention days override", 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("AUDIT_LOG_RETENTION_DAYS", "365")
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, 365, EnvConfig.AuditLogRetentionDays)
})
t.Run("should fail when AUDIT_LOG_RETENTION_DAYS is non-positive", 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("AUDIT_LOG_RETENTION_DAYS", "0")
err := parseAndValidateEnvConfig(t)
require.Error(t, err)
assert.ErrorContains(t, err, "AUDIT_LOG_RETENTION_DAYS must be greater than 0")
})
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", "PRODUCTION")
t.Setenv("APP_ENV", "STAGING")
t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, AppEnvProduction, EnvConfig.AppEnv) // lowercased
assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
@@ -186,24 +211,38 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should normalize file backend and default upload path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "FILESYSTEM")
t.Setenv("FILE_BACKEND", "FS")
t.Setenv("UPLOAD_PATH", "")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "filesystem", EnvConfig.FileBackend)
assert.Equal(t, "fs", EnvConfig.FileBackend)
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
})
t.Run("should fail when FILE_BACKEND is s3 but keys are stored on filesystem", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "s3")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
})
t.Run("should fail with invalid FILE_BACKEND value", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000")
t.Setenv("FILE_BACKEND", "invalid")
err := parseAndValidateEnvConfig(t)
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
})
@@ -240,7 +279,7 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
err := prepareEnvConfig(&config)
require.NoError(t, err)
assert.Equal(t, AppEnv("staging"), config.AppEnv)
assert.Equal(t, "staging", config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)

View File

@@ -38,13 +38,6 @@ type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type DeviceCodeInvalid struct{}
func (e *DeviceCodeInvalid) Error() string {
return "one time access code must be used on the device it was generated for"
}
func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string {

View File

@@ -23,13 +23,11 @@ func NewAppImagesController(
}
group.GET("/application-images/logo", controller.getLogoHandler)
group.GET("/application-images/email", controller.getEmailLogoHandler)
group.GET("/application-images/background", controller.getBackgroundImageHandler)
group.GET("/application-images/favicon", controller.getFaviconHandler)
group.GET("/application-images/default-profile-picture", authMiddleware.Add(), controller.getDefaultProfilePicture)
group.PUT("/application-images/logo", authMiddleware.Add(), controller.updateLogoHandler)
group.PUT("/application-images/email", authMiddleware.Add(), controller.updateEmailLogoHandler)
group.PUT("/application-images/background", authMiddleware.Add(), controller.updateBackgroundImageHandler)
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
@@ -61,18 +59,6 @@ func (c *AppImagesController) getLogoHandler(ctx *gin.Context) {
c.getImage(ctx, imageName)
}
// getEmailLogoHandler godoc
// @Summary Get email logo image
// @Description Get the email logo image for use in emails
// @Tags Application Images
// @Produce image/png
// @Produce image/jpeg
// @Success 200 {file} binary "Email logo image"
// @Router /api/application-images/email [get]
func (c *AppImagesController) getEmailLogoHandler(ctx *gin.Context) {
c.getImage(ctx, "logoEmail")
}
// getBackgroundImageHandler godoc
// @Summary Get background image
// @Description Get the background image for the application
@@ -138,37 +124,6 @@ func (c *AppImagesController) updateLogoHandler(ctx *gin.Context) {
ctx.Status(http.StatusNoContent)
}
// updateEmailLogoHandler godoc
// @Summary Update email logo
// @Description Update the email logo for use in emails
// @Tags Application Images
// @Accept multipart/form-data
// @Param file formData file true "Email logo image file"
// @Success 204 "No Content"
// @Router /api/application-images/email [put]
func (c *AppImagesController) updateEmailLogoHandler(ctx *gin.Context) {
file, err := ctx.FormFile("file")
if err != nil {
_ = ctx.Error(err)
return
}
fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(fileType)
if mimeType != "image/png" && mimeType != "image/jpeg" {
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".png or .jpg/jpeg"})
return
}
if err := c.appImagesService.UpdateImage(ctx.Request.Context(), file, "logoEmail"); err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}
// updateBackgroundImageHandler godoc
// @Summary Update background image
// @Description Update the application background image

View File

@@ -40,11 +40,6 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
return
}
if err := tc.TestService.ResetLock(c.Request.Context()); err != nil {
_ = c.Error(err)
return
}
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
_ = c.Error(err)
return
@@ -74,6 +69,8 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
}
}
tc.TestService.SetJWTKeys()
c.Status(http.StatusNoContent)
}

View File

@@ -63,8 +63,6 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.GET("/oidc/users/me/clients", authMiddleware.WithAdminNotRequired().Add(), oc.listOwnAccessibleClientsHandler)
group.GET("/oidc/clients/:id/scim-service-provider", authMiddleware.Add(), oc.getClientScimServiceProviderHandler)
}
type OidcController struct {
@@ -589,6 +587,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
// deleteClientLogoHandler godoc
@@ -615,6 +614,7 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
// updateAllowedUserGroupsHandler godoc
@@ -847,29 +847,3 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
c.JSON(http.StatusOK, preview)
}
// getClientScimServiceProviderHandler godoc
// @Summary Get SCIM service provider
// @Description Get the SCIM service provider configuration for an OIDC client
// @Tags OIDC
// @Produce json
// @Param id path string true "Client ID"
// @Success 200 {object} dto.ScimServiceProviderDTO "SCIM service provider configuration"
// @Router /api/oidc/clients/{id}/scim-service-provider [get]
func (oc *OidcController) getClientScimServiceProviderHandler(c *gin.Context) {
clientID := c.Param("id")
provider, err := oc.oidcService.GetClientScimServiceProvider(c.Request.Context(), clientID)
if err != nil {
_ = c.Error(err)
return
}
var providerDto dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, providerDto)
}

View File

@@ -1,122 +0,0 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"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"
)
func NewScimController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, scimService *service.ScimService) {
ugc := ScimController{
scimService: scimService,
}
group.POST("/scim/service-provider", authMiddleware.Add(), ugc.createServiceProviderHandler)
group.POST("/scim/service-provider/:id/sync", authMiddleware.Add(), ugc.syncServiceProviderHandler)
group.PUT("/scim/service-provider/:id", authMiddleware.Add(), ugc.updateServiceProviderHandler)
group.DELETE("/scim/service-provider/:id", authMiddleware.Add(), ugc.deleteServiceProviderHandler)
}
type ScimController struct {
scimService *service.ScimService
}
// syncServiceProviderHandler godoc
// @Summary Sync SCIM service provider
// @Description Trigger synchronization for a SCIM service provider
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 200 "OK"
// @Router /api/scim/service-provider/{id}/sync [post]
func (c *ScimController) syncServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.SyncServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusOK)
}
// createServiceProviderHandler godoc
// @Summary Create SCIM service provider
// @Description Create a new SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 201 {object} dto.ScimServiceProviderDTO "Created SCIM service provider"
// @Router /api/scim/service-provider [post]
func (c *ScimController) createServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.CreateServiceProvider(ctx.Request.Context(), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusCreated, providerDTO)
}
// updateServiceProviderHandler godoc
// @Summary Update SCIM service provider
// @Description Update an existing SCIM service provider
// @Tags SCIM
// @Accept json
// @Produce json
// @Param id path string true "Service Provider ID"
// @Param serviceProvider body dto.ScimServiceProviderCreateDTO true "SCIM service provider information"
// @Success 200 {object} dto.ScimServiceProviderDTO "Updated SCIM service provider"
// @Router /api/scim/service-provider/{id} [put]
func (c *ScimController) updateServiceProviderHandler(ctx *gin.Context) {
var input dto.ScimServiceProviderCreateDTO
if err := ctx.ShouldBindJSON(&input); err != nil {
_ = ctx.Error(err)
return
}
provider, err := c.scimService.UpdateServiceProvider(ctx.Request.Context(), ctx.Param("id"), &input)
if err != nil {
_ = ctx.Error(err)
return
}
var providerDTO dto.ScimServiceProviderDTO
if err := dto.MapStruct(provider, &providerDTO); err != nil {
_ = ctx.Error(err)
return
}
ctx.JSON(http.StatusOK, providerDTO)
}
// deleteServiceProviderHandler godoc
// @Summary Delete SCIM service provider
// @Description Delete a SCIM service provider by ID
// @Tags SCIM
// @Param id path string true "Service Provider ID"
// @Success 204 "No Content"
// @Router /api/scim/service-provider/{id} [delete]
func (c *ScimController) deleteServiceProviderHandler(ctx *gin.Context) {
err := c.scimService.DeleteServiceProvider(ctx.Request.Context(), ctx.Param("id"))
if err != nil {
_ = ctx.Error(err)
return
}
ctx.Status(http.StatusNoContent)
}

View File

@@ -72,7 +72,7 @@ type UserController struct {
// @Description Retrieve all groups a specific user belongs to
// @Tags Users,User Groups
// @Param id path string true "User ID"
// @Success 200 {array} dto.UserGroupDto
// @Success 200 {array} dto.UserGroupDtoWithUsers
// @Router /api/users/{id}/groups [get]
func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
userID := c.Param("id")
@@ -82,7 +82,7 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
return
}
var groupsDto []dto.UserGroupDto
var groupsDto []dto.UserGroupDtoWithUsers
if err := dto.MapStructList(groups, &groupsDto); err != nil {
_ = c.Error(err)
return
@@ -391,13 +391,12 @@ func (uc *UserController) RequestOneTimeAccessEmailAsUnauthenticatedUserHandler(
return
}
deviceToken, err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
err := uc.userService.RequestOneTimeAccessEmailAsUnauthenticatedUser(c.Request.Context(), input.Email, input.RedirectPath)
if err != nil {
_ = c.Error(err)
return
}
cookie.AddDeviceTokenCookie(c, deviceToken)
c.Status(http.StatusNoContent)
}
@@ -441,8 +440,7 @@ func (uc *UserController) RequestOneTimeAccessEmailAsAdminHandler(c *gin.Context
// @Success 200 {object} dto.UserDto
// @Router /api/one-time-access-token/{token} [post]
func (uc *UserController) exchangeOneTimeAccessTokenHandler(c *gin.Context) {
deviceToken, _ := c.Cookie(cookie.DeviceTokenCookieName)
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), deviceToken, c.ClientIP(), c.Request.UserAgent())
user, token, err := uc.userService.ExchangeOneTimeAccessToken(c.Request.Context(), c.Param("token"), c.ClientIP(), c.Request.UserAgent())
if err != nil {
_ = c.Error(err)
return
@@ -545,7 +543,7 @@ func (uc *UserController) createSignupTokenHandler(c *gin.Context) {
ttl = defaultSignupTokenDuration
}
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit, input.UserGroupIDs)
signupToken, err := uc.userService.CreateSignupToken(c.Request.Context(), ttl, input.UsageLimit)
if err != nil {
_ = c.Error(err)
return

View File

@@ -28,7 +28,6 @@ func NewUserGroupController(group *gin.RouterGroup, authMiddleware *middleware.A
userGroupsGroup.PUT("/:id", ugc.update)
userGroupsGroup.DELETE("/:id", ugc.delete)
userGroupsGroup.PUT("/:id/users", ugc.updateUsers)
userGroupsGroup.PUT("/:id/allowed-oidc-clients", ugc.updateAllowedOidcClients)
}
}
@@ -45,7 +44,7 @@ type UserGroupController struct {
// @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.UserGroupMinimalDto]
// @Success 200 {object} dto.Paginated[dto.UserGroupDtoWithUserCount]
// @Router /api/user-groups [get]
func (ugc *UserGroupController) list(c *gin.Context) {
searchTerm := c.Query("search")
@@ -58,9 +57,9 @@ func (ugc *UserGroupController) list(c *gin.Context) {
}
// Map the user groups to DTOs
var groupsDto = make([]dto.UserGroupMinimalDto, len(groups))
var groupsDto = make([]dto.UserGroupDtoWithUserCount, len(groups))
for i, group := range groups {
var groupDto dto.UserGroupMinimalDto
var groupDto dto.UserGroupDtoWithUserCount
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -73,7 +72,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
groupsDto[i] = groupDto
}
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupMinimalDto]{
c.JSON(http.StatusOK, dto.Paginated[dto.UserGroupDtoWithUserCount]{
Data: groupsDto,
Pagination: pagination,
})
@@ -86,7 +85,7 @@ func (ugc *UserGroupController) list(c *gin.Context) {
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Success 200 {object} dto.UserGroupDto
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Router /api/user-groups/{id} [get]
func (ugc *UserGroupController) get(c *gin.Context) {
group, err := ugc.UserGroupService.Get(c.Request.Context(), c.Param("id"))
@@ -95,7 +94,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -111,7 +110,7 @@ func (ugc *UserGroupController) get(c *gin.Context) {
// @Accept json
// @Produce json
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 201 {object} dto.UserGroupDto "Created user group"
// @Success 201 {object} dto.UserGroupDtoWithUsers "Created user group"
// @Router /api/user-groups [post]
func (ugc *UserGroupController) create(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -126,7 +125,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -143,7 +142,7 @@ func (ugc *UserGroupController) create(c *gin.Context) {
// @Produce json
// @Param id path string true "User Group ID"
// @Param userGroup body dto.UserGroupCreateDto true "User group information"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Success 200 {object} dto.UserGroupDtoWithUsers "Updated user group"
// @Router /api/user-groups/{id} [put]
func (ugc *UserGroupController) update(c *gin.Context) {
var input dto.UserGroupCreateDto
@@ -158,7 +157,7 @@ func (ugc *UserGroupController) update(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -193,7 +192,7 @@ func (ugc *UserGroupController) delete(c *gin.Context) {
// @Produce json
// @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.UserGroupDto
// @Success 200 {object} dto.UserGroupDtoWithUsers
// @Router /api/user-groups/{id}/users [put]
func (ugc *UserGroupController) updateUsers(c *gin.Context) {
var input dto.UserGroupUpdateUsersDto
@@ -208,7 +207,7 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
return
}
var groupDto dto.UserGroupDto
var groupDto dto.UserGroupDtoWithUsers
if err := dto.MapStruct(group, &groupDto); err != nil {
_ = c.Error(err)
return
@@ -216,35 +215,3 @@ func (ugc *UserGroupController) updateUsers(c *gin.Context) {
c.JSON(http.StatusOK, groupDto)
}
// updateAllowedOidcClients godoc
// @Summary Update allowed OIDC clients
// @Description Update the OIDC clients allowed for a specific user group
// @Tags OIDC
// @Accept json
// @Produce json
// @Param id path string true "User Group ID"
// @Param groups body dto.UserGroupUpdateAllowedOidcClientsDto true "OIDC client IDs to allow"
// @Success 200 {object} dto.UserGroupDto "Updated user group"
// @Router /api/user-groups/{id}/allowed-oidc-clients [put]
func (ugc *UserGroupController) updateAllowedOidcClients(c *gin.Context) {
var input dto.UserGroupUpdateAllowedOidcClientsDto
if err := c.ShouldBindJSON(&input); err != nil {
_ = c.Error(err)
return
}
userGroup, err := ugc.UserGroupService.UpdateAllowedOidcClient(c.Request.Context(), c.Param("id"), input)
if err != nil {
_ = c.Error(err)
return
}
var userGroupDto dto.UserGroupDto
if err := dto.MapStruct(userGroup, &userGroupDto); err != nil {
_ = c.Error(err)
return
}
c.JSON(http.StatusOK, userGroupDto)
}

View File

@@ -57,7 +57,7 @@ func (wc *WebauthnController) verifyRegistrationHandler(c *gin.Context) {
}
userID := c.GetString("userID")
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request, c.ClientIP())
credential, err := wc.webAuthnService.VerifyRegistration(c.Request.Context(), sessionID, userID, c.Request)
if err != nil {
_ = c.Error(err)
return
@@ -134,10 +134,8 @@ func (wc *WebauthnController) listCredentialsHandler(c *gin.Context) {
func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
userID := c.GetString("userID")
credentialID := c.Param("id")
clientIP := c.ClientIP()
userAgent := c.Request.UserAgent()
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent)
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID)
if err != nil {
_ = c.Error(err)
return

View File

@@ -47,7 +47,7 @@ type AppConfigUpdateDto struct {
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
LdapAdminGroupName string `json:"ldapAdminGroupName"`
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`

View File

@@ -18,12 +18,11 @@ type OidcClientDto struct {
IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"`
Credentials OidcClientCredentialsDto `json:"credentials"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientWithAllowedUserGroupsDto struct {
OidcClientDto
AllowedUserGroups []UserGroupMinimalDto `json:"allowedUserGroups"`
AllowedUserGroups []UserGroupDtoWithUserCount `json:"allowedUserGroups"`
}
type OidcClientWithAllowedGroupsCountDto struct {
@@ -44,7 +43,6 @@ type OidcClientUpdateDto struct {
HasDarkLogo bool `json:"hasDarkLogo"`
LogoURL *string `json:"logoUrl"`
DarkLogoURL *string `json:"darkLogoUrl"`
IsGroupRestricted bool `json:"isGroupRestricted"`
}
type OidcClientCreateDto struct {

View File

@@ -1,96 +0,0 @@
package dto
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type ScimServiceProviderDTO struct {
ID string `json:"id"`
Endpoint string `json:"endpoint"`
Token string `json:"token"`
LastSyncedAt *datatype.DateTime `json:"lastSyncedAt"`
OidcClient OidcClientMetaDataDto `json:"oidcClient"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type ScimServiceProviderCreateDTO struct {
Endpoint string `json:"endpoint" binding:"required,url"`
Token string `json:"token"`
OidcClientID string `json:"oidcClientId" binding:"required"`
}
type ScimUser struct {
ScimResourceData
UserName string `json:"userName"`
Name *ScimName `json:"name,omitempty"`
Display string `json:"displayName,omitempty"`
Active bool `json:"active"`
Emails []ScimEmail `json:"emails,omitempty"`
}
type ScimName struct {
GivenName string `json:"givenName,omitempty"`
FamilyName string `json:"familyName,omitempty"`
}
type ScimEmail struct {
Value string `json:"value"`
Primary bool `json:"primary,omitempty"`
}
type ScimGroup struct {
ScimResourceData
Display string `json:"displayName"`
Members []ScimGroupMember `json:"members,omitempty"`
}
type ScimGroupMember struct {
Value string `json:"value"`
}
type ScimListResponse[T any] struct {
Resources []T `json:"Resources"`
TotalResults int `json:"totalResults"`
StartIndex int `json:"startIndex"`
ItemsPerPage int `json:"itemsPerPage"`
}
type ScimResourceData struct {
ID string `json:"id,omitempty"`
ExternalID string `json:"externalId,omitempty"`
Schemas []string `json:"schemas"`
Meta ScimResourceMeta `json:"meta,omitempty"`
}
type ScimResourceMeta struct {
Location string `json:"location,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
Created time.Time `json:"created,omitempty"`
LastModified time.Time `json:"lastModified,omitempty"`
Version string `json:"version,omitempty"`
}
func (r ScimResourceData) GetID() string {
return r.ID
}
func (r ScimResourceData) GetExternalID() string {
return r.ExternalID
}
func (r ScimResourceData) GetSchemas() []string {
return r.Schemas
}
func (r ScimResourceData) GetMeta() ScimResourceMeta {
return r.Meta
}
type ScimResource interface {
GetID() string
GetExternalID() string
GetSchemas() []string
GetMeta() ScimResourceMeta
}

View File

@@ -6,17 +6,15 @@ import (
)
type SignupTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"required,ttl"`
UsageLimit int `json:"usageLimit" binding:"required,min=1,max=100"`
UserGroupIDs []string `json:"userGroupIds"`
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"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
CreatedAt datatype.DateTime `json:"createdAt"`
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"`
}

View File

@@ -8,31 +8,30 @@ import (
)
type UserDto struct {
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email" `
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupMinimalDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
ID string `json:"id"`
Username string `json:"username"`
Email *string `json:"email" `
FirstName string `json:"firstName"`
LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"`
Disabled bool `json:"disabled"`
}
type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
UserGroupIds []string `json:"userGroupIds"`
LdapID string `json:"-"`
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
Disabled bool `json:"disabled"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {

View File

@@ -8,17 +8,25 @@ import (
)
type UserGroupDto struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
Users []UserDto `json:"users"`
AllowedOidcClients []OidcClientMetaDataDto `json:"allowedOidcClients"`
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupMinimalDto struct {
type UserGroupDtoWithUsers struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
CustomClaims []CustomClaimDto `json:"customClaims"`
Users []UserDto `json:"users"`
LdapID *string `json:"ldapId"`
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupDtoWithUserCount struct {
ID string `json:"id"`
FriendlyName string `json:"friendlyName"`
Name string `json:"name"`
@@ -28,10 +36,6 @@ type UserGroupMinimalDto struct {
CreatedAt datatype.DateTime `json:"createdAt"`
}
type UserGroupUpdateAllowedOidcClientsDto struct {
OidcClientIDs []string `json:"oidcClientIds" binding:"required"`
}
type UserGroupCreateDto struct {
FriendlyName string `json:"friendlyName" binding:"required,min=2,max=50" unorm:"nfc"`
Name string `json:"name" binding:"required,min=2,max=255" unorm:"nfc"`

View File

@@ -19,7 +19,7 @@ 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.IsProduction() {
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}
@@ -39,7 +39,7 @@ type AnalyticsJob struct {
// 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.IsProduction() {
if common.EnvConfig.AnalyticsDisabled || common.EnvConfig.AppEnv != "production" {
return nil
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/go-co-op/gocron/v2"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
@@ -120,13 +119,11 @@ func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error {
return nil
}
// ClearAuditLogs deletes audit logs older than the configured retention window
// ClearAuditLogs deletes audit logs older than 90 days
func (j *DbCleanupJobs) clearAuditLogs(ctx context.Context) error {
cutoff := time.Now().AddDate(0, 0, -common.EnvConfig.AuditLogRetentionDays)
st := j.db.
WithContext(ctx).
Delete(&model.AuditLog{}, "created_at < ?", datatype.DateTime(cutoff))
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)
}

View File

@@ -1,26 +0,0 @@
package middleware
import "github.com/gin-gonic/gin"
// CacheControlMiddleware sets a safe default Cache-Control header on responses
// that do not already specify one. This prevents proxies from caching
// authenticated responses that might contain private data.
type CacheControlMiddleware struct {
headerValue string
}
func NewCacheControlMiddleware() *CacheControlMiddleware {
return &CacheControlMiddleware{
headerValue: "private, no-store",
}
}
func (m *CacheControlMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
if c.Writer.Header().Get("Cache-Control") == "" {
c.Header("Cache-Control", m.headerValue)
}
c.Next()
}
}

View File

@@ -1,45 +0,0 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestCacheControlMiddlewareSetsDefault(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewCacheControlMiddleware().Add())
router.GET("/test", func(c *gin.Context) {
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/test", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, "private, no-store", w.Header().Get("Cache-Control"))
}
func TestCacheControlMiddlewarePreservesExistingHeader(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
router.Use(NewCacheControlMiddleware().Add())
router.GET("/custom", func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=60")
c.Status(http.StatusOK)
})
req := httptest.NewRequest(http.MethodGet, "/custom", http.NoBody)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, "public, max-age=60", w.Header().Get("Cache-Control"))
}

View File

@@ -1,40 +0,0 @@
package middleware
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
)
type IsHeadRequestCtxKey struct{}
type headWriter struct {
gin.ResponseWriter
size int
}
func (w *headWriter) Write(b []byte) (int, error) {
w.size += len(b)
return w.size, nil
}
func HeadMiddleware() gin.HandlerFunc {
return func(c *gin.Context) {
// Only process if it's a HEAD request
if c.Request.Context().Value(IsHeadRequestCtxKey{}) != true {
c.Next()
return
}
// Replace the ResponseWriter with our headWriter to swallow the body
hw := &headWriter{ResponseWriter: c.Writer}
c.Writer = hw
c.Next()
c.Writer.Header().Set("Content-Length", strconv.Itoa(hw.size))
c.Request.Method = http.MethodHead
}
}

View File

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

View File

@@ -77,7 +77,7 @@ type AppConfig struct {
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"`
LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"`
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
}

View File

@@ -34,8 +34,6 @@ const (
AuditLogEventNewClientAuthorization AuditLogEvent = "NEW_CLIENT_AUTHORIZATION"
AuditLogEventDeviceCodeAuthorization AuditLogEvent = "DEVICE_CODE_AUTHORIZATION"
AuditLogEventNewDeviceCodeAuthorization AuditLogEvent = "NEW_DEVICE_CODE_AUTHORIZATION"
AuditLogEventPasskeyAdded AuditLogEvent = "PASSKEY_ADDED"
AuditLogEventPasskeyRemoved AuditLogEvent = "PASSKEY_REMOVED"
)
// Scan and Value methods for GORM to handle the custom type

View File

@@ -58,7 +58,6 @@ type OidcClient struct {
RequiresReauthentication bool `sortable:"true" filterable:"true"`
Credentials OidcClientCredentials
LaunchURL *string
IsGroupRestricted bool `sortable:"true" filterable:"true"`
AllowedUserGroups []UserGroup `gorm:"many2many:oidc_clients_allowed_user_groups;"`
CreatedByID *string

View File

@@ -1,14 +0,0 @@
package model
import datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
type ScimServiceProvider struct {
Base
Endpoint string `sortable:"true"`
Token datatype.EncryptedString
LastSyncedAt *datatype.DateTime `sortable:"true"`
OidcClientID string
OidcClient OidcClient `gorm:"foreignKey:OidcClientID;references:ID;"`
}

View File

@@ -13,7 +13,6 @@ type SignupToken struct {
ExpiresAt datatype.DateTime `json:"expiresAt" sortable:"true"`
UsageLimit int `json:"usageLimit" sortable:"true"`
UsageCount int `json:"usageCount" sortable:"true"`
UserGroups []UserGroup `gorm:"many2many:signup_tokens_user_groups;"`
}
func (st *SignupToken) IsExpired() bool {

View File

@@ -1,17 +0,0 @@
package model
import (
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type Storage struct {
Path string `gorm:"primaryKey"`
Data []byte
Size int64
ModTime datatype.DateTime
CreatedAt datatype.DateTime
}
func (Storage) TableName() string {
return "storage"
}

View File

@@ -11,15 +11,6 @@ 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 DateTimeFromString(str string) (DateTime, error) {
t, err := time.Parse(time.RFC3339Nano, str)
if err != nil {
return DateTime{}, fmt.Errorf("failed to parse date string: %w", err)
}
return DateTime(t), nil
}
func (date *DateTime) Scan(value any) (err error) {
switch v := value.(type) {
case time.Time:

View File

@@ -1,91 +0,0 @@
package datatype
import (
"crypto/sha256"
"database/sql/driver"
"encoding/base64"
"fmt"
"io"
"github.com/pocket-id/pocket-id/backend/internal/common"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
"golang.org/x/crypto/hkdf"
)
const encryptedStringAAD = "encrypted_string"
var encStringKey []byte
// EncryptedString stores plaintext in memory and persists encrypted data in the database.
type EncryptedString string //nolint:recvcheck
func (e *EncryptedString) Scan(value any) error {
if value == nil {
*e = ""
return nil
}
var raw string
switch v := value.(type) {
case string:
raw = v
case []byte:
raw = string(v)
default:
return fmt.Errorf("unexpected type for EncryptedString: %T", value)
}
if raw == "" {
*e = ""
return nil
}
encBytes, err := base64.StdEncoding.DecodeString(raw)
if err != nil {
return fmt.Errorf("failed to decode encrypted string: %w", err)
}
decBytes, err := cryptoutils.Decrypt(encStringKey, encBytes, []byte(encryptedStringAAD))
if err != nil {
return fmt.Errorf("failed to decrypt encrypted string: %w", err)
}
*e = EncryptedString(decBytes)
return nil
}
func (e EncryptedString) Value() (driver.Value, error) {
if e == "" {
return "", nil
}
encBytes, err := cryptoutils.Encrypt(encStringKey, []byte(e), []byte(encryptedStringAAD))
if err != nil {
return nil, fmt.Errorf("failed to encrypt string: %w", err)
}
return base64.StdEncoding.EncodeToString(encBytes), nil
}
func (e EncryptedString) String() string {
return string(e)
}
func deriveEncryptedStringKey(master []byte) ([]byte, error) {
const info = "pocketid/encrypted_string"
r := hkdf.New(sha256.New, master, nil, []byte(info))
key := make([]byte, 32)
if _, err := io.ReadFull(r, key); err != nil {
return nil, err
}
return key, nil
}
func init() {
key, err := deriveEncryptedStringKey(common.EnvConfig.EncryptionKey)
if err != nil {
panic(fmt.Sprintf("failed to derive encrypted string key: %v", err))
}
encStringKey = key
}

View File

@@ -2,7 +2,6 @@ package model
import (
"strings"
"time"
"github.com/go-webauthn/webauthn/protocol"
"github.com/go-webauthn/webauthn/webauthn"
@@ -23,7 +22,6 @@ type User struct {
Locale *string
LdapID *string
Disabled bool `sortable:"true" filterable:"true"`
UpdatedAt *datatype.DateTime
CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -87,18 +85,10 @@ func (u User) Initials() string {
return strings.ToUpper(first + last)
}
func (u User) LastModified() time.Time {
if u.UpdatedAt != nil {
return u.UpdatedAt.ToTime()
}
return u.CreatedAt.ToTime()
}
type OneTimeAccessToken struct {
Base
Token string
DeviceToken *string
ExpiresAt datatype.DateTime
Token string
ExpiresAt datatype.DateTime
UserID string
User User

View File

@@ -1,25 +1,10 @@
package model
import (
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
)
type UserGroup struct {
Base
FriendlyName string `sortable:"true"`
Name string `sortable:"true"`
LdapID *string
UpdatedAt *datatype.DateTime
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
AllowedOidcClients []OidcClient `gorm:"many2many:oidc_clients_allowed_user_groups;"`
}
func (ug UserGroup) LastModified() time.Time {
if ug.UpdatedAt != nil {
return ug.UpdatedAt.ToTime()
}
return ug.CreatedAt.ToTime()
FriendlyName string `sortable:"true"`
Name string `sortable:"true"`
LdapID *string
Users []User `gorm:"many2many:user_groups_users;"`
CustomClaims []CustomClaim
}

View File

@@ -102,7 +102,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
LdapAttributeGroupName: model.AppConfigVariable{},
LdapAdminGroupName: model.AppConfigVariable{},
LdapAttributeAdminGroup: model.AppConfigVariable{},
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
}
}

View File

@@ -1,296 +0,0 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"os"
"time"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var (
ErrLockUnavailable = errors.New("lock is already held by another process")
ErrLockLost = errors.New("lock ownership lost")
)
const (
ttl = 30 * time.Second
renewInterval = 20 * time.Second
renewRetries = 3
lockKey = "application_lock"
)
type AppLockService struct {
db *gorm.DB
lockID string
processID int64
hostID string
}
func NewAppLockService(db *gorm.DB) *AppLockService {
host, err := os.Hostname()
if err != nil || host == "" {
host = "unknown-host"
}
return &AppLockService{
db: db,
processID: int64(os.Getpid()),
hostID: host,
lockID: uuid.NewString(),
}
}
type lockValue struct {
ProcessID int64 `json:"process_id"`
HostID string `json:"host_id"`
LockID string `json:"lock_id"`
ExpiresAt int64 `json:"expires_at"`
}
func (lv *lockValue) Marshal() (string, error) {
data, err := json.Marshal(lv)
if err != nil {
return "", err
}
return string(data), nil
}
func (lv *lockValue) Unmarshal(raw string) error {
if raw == "" {
return nil
}
return json.Unmarshal([]byte(raw), lv)
}
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var prevLockRaw string
err = tx.
WithContext(ctx).
Model(&model.KV{}).
Where("key = ?", lockKey).
Clauses(clause.Locking{Strength: "UPDATE"}).
Select("value").
Scan(&prevLockRaw).
Error
if err != nil {
return time.Time{}, fmt.Errorf("query existing lock: %w", err)
}
var prevLock lockValue
if prevLockRaw != "" {
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
}
}
now := time.Now()
nowUnix := now.Unix()
value := lockValue{
ProcessID: s.processID,
HostID: s.hostID,
LockID: s.lockID,
ExpiresAt: now.Add(ttl).Unix(),
}
raw, err := value.Marshal()
if err != nil {
return time.Time{}, fmt.Errorf("encode lock value: %w", err)
}
var query string
switch s.db.Name() {
case "sqlite":
query = `
INSERT INTO kv (key, value)
VALUES (?, ?)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value
WHERE (json_extract(kv.value, '$.expires_at') < ?) OR ?
`
case "postgres":
query = `
INSERT INTO kv (key, value)
VALUES ($1, $2)
ON CONFLICT(key) DO UPDATE SET
value = excluded.value
WHERE ((kv.value::json->>'expires_at')::bigint < $3) OR ($4::boolean IS TRUE)
`
default:
return time.Time{}, fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
res := tx.WithContext(ctx).Exec(query, lockKey, raw, nowUnix, force)
if res.Error != nil {
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
}
if err := tx.Commit().Error; err != nil {
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
}
// If there is a lock that is not expired and force is false, no rows will be affected
if res.RowsAffected == 0 {
return time.Time{}, ErrLockUnavailable
}
if force && prevLock.ExpiresAt > nowUnix && prevLock.LockID != s.lockID {
waitUntil = time.Unix(prevLock.ExpiresAt, 0)
}
attrs := []any{
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
}
if wait := time.Until(waitUntil); wait > 0 {
attrs = append(attrs, slog.Duration("wait_before_proceeding", wait))
}
slog.Info("Acquired application lock", attrs...)
return waitUntil, nil
}
// RunRenewal keeps renewing the lock until the context is canceled.
func (s *AppLockService) RunRenewal(ctx context.Context) error {
ticker := time.NewTicker(renewInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return nil
case <-ticker.C:
if err := s.renew(ctx); err != nil {
return fmt.Errorf("renew lock: %w", err)
}
}
}
}
// Release releases the lock if it is held by this process.
func (s *AppLockService) Release(ctx context.Context) error {
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
defer cancel()
var query string
switch s.db.Name() {
case "sqlite":
query = `
DELETE FROM kv
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
`
case "postgres":
query = `
DELETE FROM kv
WHERE key = $1
AND value::json->>'lock_id' = $2
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
if res.Error != nil {
return fmt.Errorf("release lock failed: %w", res.Error)
}
if res.RowsAffected == 0 {
slog.Warn("Application lock not held by this process, cannot release",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
)
}
slog.Info("Released application lock",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
)
return nil
}
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
func (s *AppLockService) renew(ctx context.Context) error {
var lastErr error
for attempt := 1; attempt <= renewRetries; attempt++ {
now := time.Now()
nowUnix := now.Unix()
expiresAt := now.Add(ttl).Unix()
value := lockValue{
LockID: s.lockID,
ProcessID: s.processID,
HostID: s.hostID,
ExpiresAt: expiresAt,
}
raw, err := value.Marshal()
if err != nil {
return fmt.Errorf("encode lock value: %w", err)
}
var query string
switch s.db.Name() {
case "sqlite":
query = `
UPDATE kv
SET value = ?
WHERE key = ?
AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ?
`
case "postgres":
query = `
UPDATE kv
SET value = $1
WHERE key = $2
AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4)
`
default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
}
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
cancel()
switch {
case res.Error != nil:
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
case res.RowsAffected == 0:
// Must be after checking res.Error
return ErrLockLost
default:
slog.Debug("Renewed application lock",
slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID),
)
return nil
}
// Wait before next attempt or cancel if context is done
if attempt < renewRetries {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(1 * time.Second):
}
}
}
return lastErr
}

View File

@@ -1,189 +0,0 @@
package service
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService {
t.Helper()
return &AppLockService{
db: db,
processID: 1,
hostID: "test-host",
lockID: "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1",
}
}
func insertLock(t *testing.T, db *gorm.DB, value lockValue) {
t.Helper()
raw, err := value.Marshal()
require.NoError(t, err)
err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
require.NoError(t, err)
}
func readLockValue(t *testing.T, db *gorm.DB) lockValue {
t.Helper()
var row model.KV
err := db.Take(&row, "key = ?", lockKey).Error
require.NoError(t, err)
require.NotNil(t, row.Value)
var value lockValue
err = value.Unmarshal(*row.Value)
require.NoError(t, err)
return value
}
func TestAppLockServiceAcquire(t *testing.T) {
t.Run("creates new lock when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, service.processID, stored.ProcessID)
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 99,
HostID: "other-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
_, err := service.Acquire(context.Background(), false)
require.ErrorIs(t, err, ErrLockUnavailable)
current := readLockValue(t, db)
require.Equal(t, existing, current)
})
t.Run("force acquisition steals lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
insertLock(t, db, lockValue{
ProcessID: 99,
HostID: "other-host",
ExpiresAt: time.Now().Unix(),
})
_, err := service.Acquire(context.Background(), true)
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, service.processID, stored.ProcessID)
require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
})
}
func TestAppLockServiceRelease(t *testing.T) {
t.Run("removes owned lock", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
err = service.Release(context.Background())
require.NoError(t, err)
var row model.KV
err = db.Take(&row, "key = ?", lockKey).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
})
t.Run("ignores lock held by another owner", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
existing := lockValue{
ProcessID: 2,
HostID: "other-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
insertLock(t, db, existing)
err := service.Release(context.Background())
require.NoError(t, err)
stored := readLockValue(t, db)
require.Equal(t, existing, stored)
})
}
func TestAppLockServiceRenew(t *testing.T) {
t.Run("extends expiration when lock is still owned", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
before := readLockValue(t, db)
err = service.renew(context.Background())
require.NoError(t, err)
after := readLockValue(t, db)
require.Equal(t, service.processID, after.ProcessID)
require.Equal(t, service.hostID, after.HostID)
require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt)
})
t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
err := service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t)
service := newTestAppLockService(t, db)
_, err := service.Acquire(context.Background(), false)
require.NoError(t, err)
// Simulate a different process taking the lock.
newOwner := lockValue{
ProcessID: 9,
HostID: "stolen-host",
ExpiresAt: time.Now().Add(ttl).Unix(),
}
raw, marshalErr := newOwner.Marshal()
require.NoError(t, marshalErr)
updateErr := db.Model(&model.KV{}).
Where("key = ?", lockKey).
Update("value", raw).Error
require.NoError(t, updateErr)
err = service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost)
})
}

View File

@@ -34,7 +34,7 @@ func (s *AuditLogService) Create(ctx context.Context, event model.AuditLogEvent,
country, city, err := s.geoliteService.GetLocationByIP(ipAddress)
if err != nil {
// Log the error but don't interrupt the operation
slog.Warn("Failed to get IP location", slog.String("ip", ipAddress), slog.Any("error", err))
slog.Warn("Failed to get IP location", "error", err)
}
auditLog := model.AuditLog{
@@ -201,8 +201,8 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s
WithContext(ctx).
Joins("User").
Model(&model.AuditLog{}).
Select(`DISTINCT "User".id, "User".username`).
Where(`"User".username IS NOT NULL`)
Select("DISTINCT \"User\".id, \"User\".username").
Where("\"User\".username IS NOT NULL")
type Result struct {
ID string `gorm:"column:id"`
@@ -210,8 +210,7 @@ func (s *AuditLogService) ListUsernamesWithIds(ctx context.Context) (users map[s
}
var results []Result
err = query.Find(&results).Error
if err != nil {
if err := query.Find(&results).Error; err != nil {
return nil, fmt.Errorf("failed to query user IDs: %w", err)
}
@@ -247,8 +246,7 @@ func (s *AuditLogService) ListClientNames(ctx context.Context) (clientNames []st
}
var results []Result
err = query.Find(&results).Error
if err != nil {
if err := query.Find(&results).Error; err != nil {
return nil, fmt.Errorf("failed to query client IDs: %w", err)
}

View File

@@ -7,12 +7,14 @@ import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/base64"
"fmt"
"log/slog"
"path"
"time"
"github.com/fxamacker/cbor/v2"
"github.com/go-webauthn/webauthn/protocol"
"github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk"
@@ -34,17 +36,15 @@ type TestService struct {
appConfigService *AppConfigService
ldapService *LdapService
fileStorage storage.FileStorage
appLockService *AppLockService
externalIdPKey jwk.Key
}
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, fileStorage storage.FileStorage) (*TestService, error) {
s := &TestService{
db: db,
appConfigService: appConfigService,
jwtService: jwtService,
ldapService: ldapService,
appLockService: appLockService,
fileStorage: fileStorage,
}
err := s.initExternalIdP()
@@ -98,17 +98,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
DisplayName: "Craig Federighi",
IsAdmin: false,
},
{
Base: model.Base{
ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
},
Username: "eddy",
Email: utils.Ptr("eddy.cue@test.com"),
FirstName: "Eddy",
LastName: "Cue",
DisplayName: "Eddy Cue",
IsAdmin: false,
},
}
for _, user := range users {
if err := tx.Create(&user).Error; err != nil {
@@ -180,11 +169,10 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "606c7782-f2b1-49e5-8ea9-26eb1b06d018",
},
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: utils.Ptr(users[1].ID),
IsGroupRestricted: true,
Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{
userGroups[1],
},
@@ -197,7 +185,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Secret: "$2a$10$xcRReBsvkI1XI6FG8xu/pOgzeF00bH5Wy4d/NThwcdi3ZBpVq/B9a", // n4VfQeXlTzA6yKpWbR9uJcMdSx2qH0Lo
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
IsGroupRestricted: true,
CreatedByID: utils.Ptr(users[0].ID),
},
{
@@ -220,20 +207,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
},
},
},
{
Base: model.Base{
ID: "c46d2090-37a0-4f2b-8748-6aa53b0c1afa",
},
Name: "SCIM Client",
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
CallbackURLs: model.UrlList{"http://scimclient/auth/callback"},
CreatedByID: utils.Ptr(users[0].ID),
IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{
userGroups[0],
userGroups[1],
},
},
}
for _, client := range oidcClients {
if err := tx.Create(&client).Error; err != nil {
@@ -315,8 +288,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
publicKeyPasskey1, _ := base64.StdEncoding.DecodeString("pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=")
publicKeyPasskey2, _ := base64.StdEncoding.DecodeString("pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=")
publicKeyPasskey1, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
publicKeyPasskey2, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
webauthnCredentials := []model.WebauthnCredential{
{
Name: "Passkey 1",
@@ -345,10 +318,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Challenge: "challenge",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserVerification: "preferred",
CredentialParams: model.CredentialParameters{
{Type: "public-key", Algorithm: -7},
{Type: "public-key", Algorithm: -257},
},
}
if err := tx.Create(&webauthnSession).Error; err != nil {
return err
@@ -358,10 +327,9 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
},
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
Name: "Test API Key",
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
UserID: users[0].ID,
}
if err := tx.Create(&apiKey).Error; err != nil {
return err
@@ -376,9 +344,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
UsageLimit: 1,
UsageCount: 0,
UserGroups: []model.UserGroup{
userGroups[0],
},
},
{
Base: model.Base{
@@ -414,20 +379,6 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}
}
keyValues := []model.KV{
{
Key: jwkutils.PrivateKeyDBKey,
// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
Value: utils.Ptr("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
},
}
for _, kv := range keyValues {
if err := tx.Create(&kv).Error; err != nil {
return err
}
}
return nil
})
@@ -475,8 +426,7 @@ func (s *TestService) ResetDatabase() error {
}
func (s *TestService) ResetApplicationImages(ctx context.Context) error {
err := s.fileStorage.DeleteAll(ctx, "/")
if err != nil {
if err := s.fileStorage.DeleteAll(ctx, "/"); err != nil {
slog.ErrorContext(ctx, "Error removing uploads", slog.Any("error", err))
return err
}
@@ -495,8 +445,7 @@ func (s *TestService) ResetApplicationImages(ctx context.Context) error {
if err != nil {
return err
}
err = s.fileStorage.Save(ctx, path.Join("application-images", file.Name()), srcFile)
if err != nil {
if err := s.fileStorage.Save(ctx, path.Join("application-images", file.Name()), srcFile); err != nil {
srcFile.Close()
return err
}
@@ -513,29 +462,47 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
return err
}
// Manually set instance ID
err = s.appConfigService.UpdateAppConfigValues(ctx, "instanceId", "test-instance-id")
if err != nil {
return err
}
// Reload the app config from the database after resetting the values
err = s.appConfigService.LoadDbConfig(ctx)
if err != nil {
return err
}
// Reload the JWK
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
return err
}
return nil
return s.appConfigService.LoadDbConfig(ctx)
}
func (s *TestService) ResetLock(ctx context.Context) error {
_, err := s.appLockService.Acquire(ctx, true)
return err
func (s *TestService) SetJWTKeys() {
const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`
privateKey, _ := jwk.ParseKey([]byte(privateKeyString))
_ = s.jwtService.SetKey(privateKey)
}
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
if err != nil {
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
}
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
if err != nil {
return nil, fmt.Errorf("failed to parse public key: %w", err)
}
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
if !ok {
return nil, fmt.Errorf("not an ECDSA public key")
}
coseKey := map[int]interface{}{
1: 2, // Key type: EC2
3: -7, // Algorithm: ECDSA with SHA-256
-1: 1, // Curve: P-256
-2: ecdsaPubKey.X.Bytes(), // X coordinate
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
}
cborPublicKey, err := cbor.Marshal(coseKey)
if err != nil {
return nil, fmt.Errorf("failed to marshal COSE key: %w", err)
}
return cborPublicKey, nil
}
// SyncLdap triggers an LDAP synchronization
@@ -562,7 +529,7 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
"ldapAttributeGroupUniqueIdentifier": "uuid",
"ldapAttributeGroupName": "uid",
"ldapAttributeGroupMember": "member",
"ldapAdminGroupName": "admin_group",
"ldapAttributeAdminGroup": "admin_group",
"ldapSoftDeleteUsers": "true",
"ldapEnabled": "true",
}

View File

@@ -78,7 +78,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
data := &email.TemplateData[V]{
AppName: dbConfig.AppName.Value,
LogoURL: common.EnvConfig.AppURL + "/api/application-images/email",
LogoURL: common.EnvConfig.AppURL + "/api/application-images/logo",
Data: tData,
}

View File

@@ -1,217 +0,0 @@
package service
import (
"archive/zip"
"context"
"encoding/json"
"fmt"
"io"
"path/filepath"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// ExportService handles exporting Pocket ID data into a ZIP archive.
type ExportService struct {
db *gorm.DB
storage storage.FileStorage
}
func NewExportService(db *gorm.DB, storage storage.FileStorage) *ExportService {
return &ExportService{
db: db,
storage: storage,
}
}
// ExportToZip performs the full export process and writes the ZIP data to the given writer.
func (s *ExportService) ExportToZip(ctx context.Context, w io.Writer) error {
dbData, err := s.extractDatabase()
if err != nil {
return err
}
return s.writeExportZipStream(ctx, w, dbData)
}
// extractDatabase reads all tables into a DatabaseExport struct
func (s *ExportService) extractDatabase() (DatabaseExport, error) {
schema, err := utils.LoadDBSchemaTypes(s.db)
if err != nil {
return DatabaseExport{}, fmt.Errorf("failed to load schema types: %w", err)
}
version, err := s.schemaVersion()
if err != nil {
return DatabaseExport{}, err
}
out := DatabaseExport{
Provider: s.db.Name(),
Version: version,
Tables: map[string][]map[string]any{},
// These tables need to be inserted in a specific order because of foreign key constraints
// Not all tables are listed here, because not all tables are order-dependent
TableOrder: []string{"users", "user_groups", "oidc_clients", "signup_tokens"},
}
for table := range schema {
if table == "storage" || table == "schema_migrations" {
continue
}
err = s.dumpTable(table, schema[table], &out)
if err != nil {
return DatabaseExport{}, err
}
}
return out, nil
}
func (s *ExportService) schemaVersion() (uint, error) {
var version uint
if err := s.db.Raw("SELECT version FROM schema_migrations").Row().Scan(&version); err != nil {
return 0, fmt.Errorf("failed to query schema version: %w", err)
}
return version, nil
}
// dumpTable selects all rows from a table and appends them to out.Tables
func (s *ExportService) dumpTable(table string, types utils.DBSchemaTableTypes, out *DatabaseExport) error {
rows, err := s.db.Raw("SELECT * FROM " + table).Rows()
if err != nil {
return fmt.Errorf("failed to read table %s: %w", table, err)
}
defer rows.Close()
cols, _ := rows.Columns()
if len(cols) != len(types) {
// Should never happen...
return fmt.Errorf("mismatched columns in table (%d) and schema (%d)", len(cols), len(types))
}
for rows.Next() {
vals := s.getScanValuesForTable(cols, types)
err = rows.Scan(vals...)
if err != nil {
return fmt.Errorf("failed to scan row in table %s: %w", table, err)
}
rowMap := make(map[string]any, len(cols))
for i, col := range cols {
rowMap[col] = vals[i]
}
// Skip the app lock row in the kv table
if table == "kv" {
if keyPtr, ok := rowMap["key"].(*string); ok && keyPtr != nil && *keyPtr == lockKey {
continue
}
}
out.Tables[table] = append(out.Tables[table], rowMap)
}
return rows.Err()
}
func (s *ExportService) getScanValuesForTable(cols []string, types utils.DBSchemaTableTypes) []any {
res := make([]any, len(cols))
for i, col := range cols {
// Store a pointer
// Note: don't create a helper function for this switch, because it would return type "any" and mess everything up
// If the column is nullable, we need a pointer to a pointer!
switch types[col].Name {
case "boolean", "bool":
var x bool
if types[col].Nullable {
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = utils.Ptr(x)
}
case "blob", "bytea", "jsonb":
// Treat jsonb columns as binary too
var x []byte
if types[col].Nullable {
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = utils.Ptr(x)
}
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
var x datatype.DateTime
if types[col].Nullable {
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = utils.Ptr(x)
}
case "integer", "int", "bigint":
var x int64
if types[col].Nullable {
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = utils.Ptr(x)
}
default:
// Treat everything else as a string (including the "numeric" type)
var x string
if types[col].Nullable {
res[i] = utils.Ptr(utils.Ptr(x))
} else {
res[i] = utils.Ptr(x)
}
}
}
return res
}
func (s *ExportService) writeExportZipStream(ctx context.Context, w io.Writer, dbData DatabaseExport) error {
zipWriter := zip.NewWriter(w)
// Add database.json
jsonWriter, err := zipWriter.Create("database.json")
if err != nil {
return fmt.Errorf("failed to create database.json in zip: %w", err)
}
jsonEncoder := json.NewEncoder(jsonWriter)
jsonEncoder.SetEscapeHTML(false)
if err := jsonEncoder.Encode(dbData); err != nil {
return fmt.Errorf("failed to encode database.json: %w", err)
}
// Add uploaded files
if err := s.addUploadsToZip(ctx, zipWriter); err != nil {
return err
}
return zipWriter.Close()
}
// addUploadsToZip adds all files from the storage to the ZIP archive under the "uploads/" directory
func (s *ExportService) addUploadsToZip(ctx context.Context, zipWriter *zip.Writer) error {
return s.storage.Walk(ctx, "/", func(p storage.ObjectInfo) error {
zipPath := filepath.Join("uploads", p.Path)
w, err := zipWriter.Create(zipPath)
if err != nil {
return fmt.Errorf("failed to create zip entry for %s: %w", zipPath, err)
}
f, _, err := s.storage.Open(ctx, p.Path)
if err != nil {
return fmt.Errorf("failed to open file %s: %w", zipPath, err)
}
defer f.Close()
if _, err := io.Copy(w, f); err != nil {
return fmt.Errorf("failed to copy file %s into zip: %w", zipPath, err)
}
return nil
})
}

View File

@@ -1,272 +0,0 @@
package service
import (
"archive/zip"
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"slices"
"strings"
"gorm.io/gorm"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// ImportService handles importing Pocket ID data from an exported ZIP archive.
type ImportService struct {
db *gorm.DB
storage storage.FileStorage
}
type DatabaseExport struct {
Provider string `json:"provider"`
Version uint `json:"version"`
Tables map[string][]map[string]any `json:"tables"`
TableOrder []string `json:"tableOrder"`
}
func NewImportService(db *gorm.DB, storage storage.FileStorage) *ImportService {
return &ImportService{
db: db,
storage: storage,
}
}
// ImportFromZip performs the full import process from the given ZIP reader.
func (s *ImportService) ImportFromZip(ctx context.Context, r *zip.Reader) error {
dbData, err := processZipDatabaseJson(r.File)
if err != nil {
return err
}
err = s.ImportDatabase(dbData)
if err != nil {
return err
}
err = s.importUploads(ctx, r.File)
if err != nil {
return err
}
return nil
}
// ImportDatabase only imports the database data from the given DatabaseExport struct.
func (s *ImportService) ImportDatabase(dbData DatabaseExport) error {
err := s.resetSchema(dbData.Version)
if err != nil {
return err
}
err = s.insertData(dbData)
if err != nil {
return err
}
return nil
}
// processZipDatabaseJson extracts database.json from the ZIP archive
func processZipDatabaseJson(files []*zip.File) (dbData DatabaseExport, err error) {
for _, f := range files {
if f.Name == "database.json" {
return parseDatabaseJsonStream(f)
}
}
return dbData, errors.New("database.json not found in the ZIP file")
}
func parseDatabaseJsonStream(f *zip.File) (dbData DatabaseExport, err error) {
rc, err := f.Open()
if err != nil {
return dbData, fmt.Errorf("failed to open database.json: %w", err)
}
defer rc.Close()
err = json.NewDecoder(rc).Decode(&dbData)
if err != nil {
return dbData, fmt.Errorf("failed to decode database.json: %w", err)
}
return dbData, nil
}
// importUploads imports files from the uploads/ directory in the ZIP archive
func (s *ImportService) importUploads(ctx context.Context, files []*zip.File) error {
const maxFileSize = 50 << 20 // 50 MiB
const uploadsPrefix = "uploads/"
for _, f := range files {
if !strings.HasPrefix(f.Name, uploadsPrefix) {
continue
}
if f.UncompressedSize64 > maxFileSize {
return fmt.Errorf("file %s too large (%d bytes)", f.Name, f.UncompressedSize64)
}
targetPath := strings.TrimPrefix(f.Name, uploadsPrefix)
if strings.HasSuffix(f.Name, "/") || targetPath == "" {
continue // Skip directories
}
err := s.storage.DeleteAll(ctx, targetPath)
if err != nil {
return fmt.Errorf("failed to delete existing file %s: %w", targetPath, err)
}
rc, err := f.Open()
if err != nil {
return err
}
buf, err := io.ReadAll(rc)
rc.Close()
if err != nil {
return fmt.Errorf("read file %s: %w", f.Name, err)
}
err = s.storage.Save(ctx, targetPath, bytes.NewReader(buf))
if err != nil {
return fmt.Errorf("failed to save file %s: %w", targetPath, err)
}
}
return nil
}
// resetSchema drops the existing schema and migrates to the target version
func (s *ImportService) resetSchema(targetVersion uint) error {
sqlDb, err := s.db.DB()
if err != nil {
return fmt.Errorf("failed to get sql.DB: %w", err)
}
m, err := utils.GetEmbeddedMigrateInstance(sqlDb)
if err != nil {
return fmt.Errorf("failed to get migrate instance: %w", err)
}
if s.db.Name() == "sqlite" {
s.db.Exec("PRAGMA foreign_keys = OFF;")
}
err = m.Drop()
if err != nil {
return fmt.Errorf("failed to drop existing schema: %w", err)
}
if s.db.Name() == "sqlite" {
defer s.db.Exec("PRAGMA foreign_keys = ON;")
}
// Needs to be called again to re-create the schema_migrations table
m, err = utils.GetEmbeddedMigrateInstance(sqlDb)
if err != nil {
return fmt.Errorf("failed to get migrate instance: %w", err)
}
err = m.Migrate(targetVersion)
if err != nil {
return fmt.Errorf("migration failed: %w", err)
}
return nil
}
// insertData populates the DB with the imported data
func (s *ImportService) insertData(dbData DatabaseExport) error {
schema, err := utils.LoadDBSchemaTypes(s.db)
if err != nil {
return fmt.Errorf("failed to load schema types: %w", err)
}
return s.db.Transaction(func(tx *gorm.DB) error {
// Iterate through all tables
// Some tables need to be processed in order
tables := make([]string, 0, len(dbData.Tables))
tables = append(tables, dbData.TableOrder...)
for t := range dbData.Tables {
// Skip tables already present where the order matters
// Also skip the schema_migrations table
if slices.Contains(dbData.TableOrder, t) || t == "schema_migrations" {
continue
}
tables = append(tables, t)
}
// Insert rows
for _, table := range tables {
for _, row := range dbData.Tables[table] {
err = normalizeRowWithSchema(row, table, schema)
if err != nil {
return fmt.Errorf("failed to normalize row for table '%s': %w", table, err)
}
err = tx.Table(table).Create(row).Error
if err != nil {
return fmt.Errorf("failed inserting into table '%s': %w", table, err)
}
}
}
return nil
})
}
// normalizeRowWithSchema converts row values based on the DB schema
func normalizeRowWithSchema(row map[string]any, table string, schema utils.DBSchemaTypes) error {
if schema[table] == nil {
return fmt.Errorf("schema not found for table '%s'", table)
}
for col, val := range row {
if val == nil {
// If the value is nil, skip the column
continue
}
colType := schema[table][col]
switch colType.Name {
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
// Dates are stored as strings
str, ok := val.(string)
if !ok {
return fmt.Errorf("value for column '%s/%s' was expected to be a string, but was '%T'", table, col, val)
}
d, err := datatype.DateTimeFromString(str)
if err != nil {
return fmt.Errorf("failed to decode value for column '%s/%s' as timestamp: %w", table, col, err)
}
row[col] = d
case "blob", "bytea", "jsonb":
// Binary data and jsonb data is stored in the file as base64-encoded string
str, ok := val.(string)
if !ok {
return fmt.Errorf("value for column '%s/%s' was expected to be a string, but was '%T'", table, col, val)
}
b, err := base64.StdEncoding.DecodeString(str)
if err != nil {
return fmt.Errorf("failed to decode value for column '%s/%s' from base64: %w", table, col, err)
}
// For jsonb, we additionally cast to json.RawMessage
if colType.Name == "jsonb" {
row[col] = json.RawMessage(b)
} else {
row[col] = b
}
}
}
return nil
}

View File

@@ -18,6 +18,14 @@ import (
)
const (
// PrivateKeyFile is the path in the data/keys folder where the key is stored
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
// This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
// KeyUsageSigning is the usage for the private keys, for the "use" property
KeyUsageSigning = "sig"
@@ -48,7 +56,6 @@ const (
)
type JwtService struct {
db *gorm.DB
envConfig *common.EnvConfigSchema
privateKey jwk.Key
keyId string
@@ -59,6 +66,7 @@ type JwtService struct {
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
service := &JwtService{}
// Ensure keys are generated or loaded
err := service.init(db, appConfigService, &common.EnvConfig)
if err != nil {
return nil, err
@@ -70,15 +78,14 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
s.appConfigService = appConfigService
s.envConfig = envConfig
s.db = db
// Ensure keys are generated or loaded
return s.LoadOrGenerateKey()
return s.loadOrGenerateKey(db)
}
func (s *JwtService) LoadOrGenerateKey() error {
func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
// Get the key provider
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
if err != nil {
return fmt.Errorf("failed to get key provider: %w", err)
}
@@ -86,7 +93,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
// Try loading a key
key, err := keyProvider.LoadKey()
if err != nil {
return fmt.Errorf("failed to load key: %w", err)
return fmt.Errorf("failed to load key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
// If we have a key, store it in the object and we're done
@@ -107,7 +114,7 @@ func (s *JwtService) LoadOrGenerateKey() error {
// Save the newly-generated key
err = keyProvider.SaveKey(s.privateKey)
if err != nil {
return fmt.Errorf("failed to save private key: %w", err)
return fmt.Errorf("failed to save private key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
}
return nil

File diff suppressed because it is too large Load Diff

View File

@@ -11,14 +11,12 @@ import (
"log/slog"
"net/http"
"net/url"
"path"
"strings"
"time"
"unicode/utf8"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"golang.org/x/text/unicode/norm"
"gorm.io/gorm"
@@ -34,23 +32,15 @@ type LdapService struct {
appConfigService *AppConfigService
userService *UserService
groupService *UserGroupService
fileStorage storage.FileStorage
}
type savePicture struct {
userID string
username string
picture string
}
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService) *LdapService {
return &LdapService{
db: db,
httpClient: httpClient,
appConfigService: appConfigService,
userService: userService,
groupService: groupService,
fileStorage: fileStorage,
}
}
@@ -78,6 +68,12 @@ func (s *LdapService) createClient() (*ldap.Conn, error) {
}
func (s *LdapService) SyncAll(ctx context.Context) error {
// Start a transaction
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// Setup LDAP connection
client, err := s.createClient()
if err != nil {
@@ -85,13 +81,7 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
}
defer client.Close()
// Start a transaction
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
err = s.SyncUsers(ctx, tx, client)
if err != nil {
return fmt.Errorf("failed to sync users: %w", err)
}
@@ -107,25 +97,6 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
return fmt.Errorf("failed to commit changes to database: %w", err)
}
// Now that we've committed the transaction, we can perform operations on the storage layer
// First, save all new pictures
for _, sp := range savePictures {
err = s.saveProfilePicture(ctx, sp.userID, sp.picture)
if err != nil {
// This is not a fatal error
slog.Warn("Error saving profile picture for LDAP user", slog.String("username", sp.username), slog.Any("error", err))
}
}
// Delete all old files
for _, path := range deleteFiles {
err = s.fileStorage.Delete(ctx, path)
if err != nil {
// This is not a fatal error
slog.Error("Failed to delete file after LDAP sync", slog.String("path", path), slog.Any("error", err))
}
}
return nil
}
@@ -295,7 +266,7 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
}
//nolint:gocognit
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
dbConfig := s.appConfigService.GetDbConfig()
searchAttrs := []string{
@@ -323,12 +294,11 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
result, err := client.Search(searchReq)
if err != nil {
return nil, nil, fmt.Errorf("failed to query LDAP: %w", err)
return fmt.Errorf("failed to query LDAP: %w", err)
}
// Create a mapping for users that exist
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
savePictures = make([]savePicture, 0, len(result.Entries))
for _, value := range result.Entries {
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
@@ -359,19 +329,19 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
return fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
}
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
// This could error with ErrRecordNotFound and we want to ignore that here
return nil, nil, fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
return fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
}
// Check if user is admin by checking if they are in the admin group
isAdmin := false
for _, group := range value.GetAttributeValues("memberOf") {
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAttributeAdminGroup.Value {
isAdmin = true
break
}
@@ -399,35 +369,32 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
continue
}
userID := databaseUser.ID
if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
_, err = s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
return fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
}
userID = createdUser.ID
} else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue
} else if err != nil {
return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
return fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
}
}
// Save profile picture
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
if pictureString != "" {
// Storage operations must be executed outside of a transaction
savePictures = append(savePictures, savePicture{
userID: databaseUser.ID,
username: userID,
picture: pictureString,
})
err = s.saveProfilePicture(ctx, databaseUser.ID, pictureString)
if err != nil {
// This is not a fatal error
slog.Warn("Error saving profile picture for user", slog.String("username", newUser.Username), slog.Any("error", err))
}
}
}
@@ -439,11 +406,10 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
Select("id, username, ldap_id, disabled").
Error
if err != nil {
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
return fmt.Errorf("failed to fetch users from database: %w", err)
}
// Mark users as disabled or delete users that no longer exist in LDAP
deleteFiles = make([]string, 0, len(ldapUserIDs))
for _, user := range ldapUsersInDb {
// Skip if the user ID exists in the fetched LDAP results
if _, exists := ldapUserIDs[*user.LdapID]; exists {
@@ -451,34 +417,30 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
}
if dbConfig.LdapSoftDeleteUsers.IsTrue() {
err = s.userService.disableUserInternal(ctx, tx, user.ID)
err = s.userService.disableUserInternal(ctx, user.ID, tx)
if err != nil {
return nil, nil, fmt.Errorf("failed to disable user %s: %w", user.Username, err)
return fmt.Errorf("failed to disable user %s: %w", user.Username, err)
}
slog.Info("Disabled user", slog.String("username", user.Username))
} else {
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
}
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
err = s.userService.deleteUserInternal(ctx, user.ID, true, tx)
target := &common.LdapUserUpdateError{}
if errors.As(err, &target) {
return fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
} else if err != nil {
return fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
slog.Info("Deleted user", slog.String("username", user.Username))
// Storage operations must be executed outside of a transaction
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
}
}
return savePictures, deleteFiles, nil
return nil
}
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
var reader io.ReadSeeker
var reader io.Reader
_, err := url.ParseRequestURI(pictureString)
if err == nil {
@@ -498,12 +460,7 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
}
defer res.Body.Close()
data, err := io.ReadAll(res.Body)
if err != nil {
return fmt.Errorf("failed to read profile picture: %w", err)
}
reader = bytes.NewReader(data)
reader = res.Body
} else if decodedPhoto, err := base64.StdEncoding.DecodeString(pictureString); err == nil {
// If the photo is a base64 encoded string, decode it
reader = bytes.NewReader(decodedPhoto)

View File

@@ -12,9 +12,11 @@ import (
"io"
"log/slog"
"mime/multipart"
"net"
"net/http"
"net/url"
"path"
"regexp"
"slices"
"strings"
"time"
@@ -168,7 +170,7 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
return "", "", err
}
if !IsUserGroupAllowedToAuthorize(user, client) {
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return "", "", &common.OidcAccessDeniedError{}
}
@@ -224,8 +226,8 @@ func (s *OidcService) hasAuthorizedClientInternal(ctx context.Context, clientID,
}
// IsUserGroupAllowedToAuthorize checks if the user group of the user is allowed to authorize the client
func IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if !client.IsGroupRestricted {
func (s *OidcService) IsUserGroupAllowedToAuthorize(user model.User, client model.OidcClient) bool {
if len(client.AllowedUserGroups) == 0 {
return true
}
@@ -677,21 +679,19 @@ func (s *OidcService) introspectRefreshToken(ctx context.Context, clientID strin
}
func (s *OidcService) GetClient(ctx context.Context, clientID string) (model.OidcClient, error) {
return s.getClientInternal(ctx, clientID, s.db, false)
return s.getClientInternal(ctx, clientID, s.db)
}
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB, forUpdate bool) (model.OidcClient, error) {
func (s *OidcService) getClientInternal(ctx context.Context, clientID string, tx *gorm.DB) (model.OidcClient, error) {
var client model.OidcClient
q := tx.
err := tx.
WithContext(ctx).
Preload("CreatedBy").
Preload("AllowedUserGroups")
if forUpdate {
q = q.Clauses(clause.Locking{Strength: "UPDATE"})
}
q = q.First(&client, "id = ?", clientID)
if q.Error != nil {
return model.OidcClient{}, q.Error
Preload("AllowedUserGroups").
First(&client, "id = ?", clientID).
Error
if err != nil {
return model.OidcClient{}, err
}
return client, nil
}
@@ -724,6 +724,11 @@ func (s *OidcService) ListClients(ctx context.Context, name string, listRequestO
}
func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCreateDto, userID string) (model.OidcClient, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
client := model.OidcClient{
Base: model.Base{
ID: input.ID,
@@ -732,7 +737,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
}
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
err := s.db.
err := tx.
WithContext(ctx).
Create(&client).
Error
@@ -743,73 +748,62 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
return model.OidcClient{}, err
}
// All storage operations must be executed outside of a transaction
if input.LogoURL != nil {
err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.LogoURL, true)
err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL, true)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
}
}
if input.DarkLogoURL != nil {
err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.DarkLogoURL, false)
err = s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.DarkLogoURL, false)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err)
}
}
return client, nil
}
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.WithContext(ctx).
Preload("CreatedBy").
First(&client, "id = ?", clientID).Error
if err != nil {
return model.OidcClient{}, err
}
updateOIDCClientModelFromDto(&client, &input)
if !input.IsGroupRestricted {
// Clear allowed user groups if the restriction is removed
err = tx.Model(&client).Association("AllowedUserGroups").Clear()
if err != nil {
return model.OidcClient{}, err
}
}
err = tx.WithContext(ctx).Save(&client).Error
if err != nil {
return model.OidcClient{}, err
}
err = tx.Commit().Error
if err != nil {
return model.OidcClient{}, err
}
// All storage operations must be executed outside of a transaction
return client, nil
}
func (s *OidcService) UpdateClient(ctx context.Context, clientID string, input dto.OidcClientUpdateDto) (model.OidcClient, error) {
tx := s.db.Begin()
defer func() { tx.Rollback() }()
var client model.OidcClient
if err := tx.WithContext(ctx).
Preload("CreatedBy").
First(&client, "id = ?", clientID).Error; err != nil {
return model.OidcClient{}, err
}
updateOIDCClientModelFromDto(&client, &input)
if err := tx.WithContext(ctx).Save(&client).Error; err != nil {
return model.OidcClient{}, err
}
if input.LogoURL != nil {
err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.LogoURL, true)
err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.LogoURL, true)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download logo: %w", err)
}
}
if input.DarkLogoURL != nil {
err = s.downloadAndSaveLogoFromURL(ctx, client.ID, *input.DarkLogoURL, false)
err := s.downloadAndSaveLogoFromURL(ctx, tx, client.ID, *input.DarkLogoURL, false)
if err != nil {
return model.OidcClient{}, fmt.Errorf("failed to download dark logo: %w", err)
}
}
if err := tx.Commit().Error; err != nil {
return model.OidcClient{}, err
}
return client, nil
}
@@ -823,7 +817,6 @@ func updateOIDCClientModelFromDto(client *model.OidcClient, input *dto.OidcClien
client.PkceEnabled = input.IsPublic || input.PkceEnabled
client.RequiresReauthentication = input.RequiresReauthentication
client.LaunchURL = input.LaunchURL
client.IsGroupRestricted = input.IsGroupRestricted
// Credentials
client.Credentials.FederatedIdentities = make([]model.OidcClientFederatedIdentity, len(input.Credentials.FederatedIdentities))
@@ -843,24 +836,12 @@ func (s *OidcService) DeleteClient(ctx context.Context, clientID string) error {
err := s.db.
WithContext(ctx).
Where("id = ?", clientID).
Clauses(clause.Returning{}).
Delete(&client).
Error
if err != nil {
return err
}
// Delete images if present
// Note that storage operations must be done outside of a transaction
if client.ImageType != nil && *client.ImageType != "" {
old := path.Join("oidc-client-images", client.ID+"."+*client.ImageType)
_ = s.fileStorage.Delete(ctx, old)
}
if client.DarkImageType != nil && *client.DarkImageType != "" {
old := path.Join("oidc-client-images", client.ID+"-dark."+*client.DarkImageType)
_ = s.fileStorage.Delete(ctx, old)
}
return nil
}
@@ -960,44 +941,22 @@ func (s *OidcService) UpdateClientLogo(ctx context.Context, clientID string, fil
return err
}
defer reader.Close()
err = s.fileStorage.Save(ctx, imagePath, reader)
if err != nil {
if err := s.fileStorage.Save(ctx, imagePath, reader); err != nil {
return err
}
err = s.updateClientLogoType(ctx, clientID, fileType, light)
tx := s.db.Begin()
err = s.updateClientLogoType(ctx, tx, clientID, fileType, light)
if err != nil {
tx.Rollback()
return err
}
return nil
return tx.Commit().Error
}
func (s *OidcService) DeleteClientLogo(ctx context.Context, clientID string) error {
return s.deleteClientLogoInternal(ctx, clientID, "", func(client *model.OidcClient) (string, error) {
if client.ImageType == nil {
return "", errors.New("image not found")
}
oldImageType := *client.ImageType
client.ImageType = nil
return oldImageType, nil
})
}
func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error {
return s.deleteClientLogoInternal(ctx, clientID, "-dark", func(client *model.OidcClient) (string, error) {
if client.DarkImageType == nil {
return "", errors.New("image not found")
}
oldImageType := *client.DarkImageType
client.DarkImageType = nil
return oldImageType, nil
})
}
func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID string, imagePathSuffix string, setClientImage func(*model.OidcClient) (string, error)) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -1012,11 +971,13 @@ func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID str
return err
}
oldImageType, err := setClientImage(&client)
if err != nil {
return err
if client.ImageType == nil {
return errors.New("image not found")
}
oldImageType := *client.ImageType
client.ImageType = nil
err = tx.
WithContext(ctx).
Save(&client).
@@ -1025,14 +986,55 @@ func (s *OidcService) deleteClientLogoInternal(ctx context.Context, clientID str
return err
}
imagePath := path.Join("oidc-client-images", client.ID+"."+oldImageType)
if err := s.fileStorage.Delete(ctx, imagePath); err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
// All storage operations must be performed outside of a database transaction
imagePath := path.Join("oidc-client-images", client.ID+imagePathSuffix+"."+oldImageType)
err = s.fileStorage.Delete(ctx, imagePath)
return nil
}
func (s *OidcService) DeleteClientDarkLogo(ctx context.Context, clientID string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
var client model.OidcClient
err := tx.
WithContext(ctx).
First(&client, "id = ?", clientID).
Error
if err != nil {
return err
}
if client.DarkImageType == nil {
return errors.New("image not found")
}
oldImageType := *client.DarkImageType
client.DarkImageType = nil
err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return err
}
imagePath := path.Join("oidc-client-images", client.ID+"-dark."+oldImageType)
if err := s.fileStorage.Delete(ctx, imagePath); err != nil {
return err
}
err = tx.Commit().Error
if err != nil {
return err
}
@@ -1046,7 +1048,7 @@ func (s *OidcService) UpdateAllowedUserGroups(ctx context.Context, id string, in
tx.Rollback()
}()
client, err = s.getClientInternal(ctx, id, tx, true)
client, err = s.getClientInternal(ctx, id, tx)
if err != nil {
return model.OidcClient{}, err
}
@@ -1204,7 +1206,7 @@ func (s *OidcService) getCallbackURL(client *model.OidcClient, inputCallbackURL
// If URLs are already configured, validate against them
if len(client.CallbackURLs) > 0 {
matched, err := utils.GetCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
matched, err := s.getCallbackURLFromList(client.CallbackURLs, inputCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
@@ -1227,7 +1229,7 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
return client.LogoutCallbackURLs[0], nil
}
matched, err := utils.GetCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
matched, err := s.getCallbackURLFromList(client.LogoutCallbackURLs, inputLogoutCallbackURL)
if err != nil {
return "", err
} else if matched == "" {
@@ -1237,6 +1239,21 @@ func (s *OidcService) getLogoutCallbackURL(client *model.OidcClient, inputLogout
return matched, nil
}
func (s *OidcService) getCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
for _, callbackPattern := range urls {
regexPattern := "^" + strings.ReplaceAll(regexp.QuoteMeta(callbackPattern), `\*`, ".*") + "$"
matched, err := regexp.MatchString(regexPattern, inputCallbackURL)
if err != nil {
return "", err
}
if matched {
return inputCallbackURL, nil
}
}
return "", nil
}
func (s *OidcService) addCallbackURLToClient(ctx context.Context, client *model.OidcClient, callbackURL string, tx *gorm.DB) error {
// Add the new callback URL to the existing list
client.CallbackURLs = append(client.CallbackURLs, callbackURL)
@@ -1325,7 +1342,7 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
return fmt.Errorf("error finding user groups: %w", err)
}
if !IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
if !s.IsUserGroupAllowedToAuthorize(user, deviceAuth.Client) {
return &common.OidcAccessDeniedError{}
}
@@ -1814,7 +1831,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
tx.Rollback()
}()
client, err := s.getClientInternal(ctx, clientID, tx, false)
client, err := s.getClientInternal(ctx, clientID, tx)
if err != nil {
return nil, err
}
@@ -1829,7 +1846,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, err
}
if !IsUserGroupAllowedToAuthorize(user, client) {
if !s.IsUserGroupAllowedToAuthorize(user, client) {
return nil, &common.OidcAccessDeniedError{}
}
@@ -1956,28 +1973,10 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
return false, err
}
return IsUserGroupAllowedToAuthorize(user, client), nil
return s.IsUserGroupAllowedToAuthorize(user, client), nil
}
var errLogoTooLarge = errors.New("logo is too large")
func httpClientWithCheckRedirect(source *http.Client, checkRedirect func(req *http.Request, via []*http.Request) error) *http.Client {
if source == nil {
source = http.DefaultClient
}
// Create a new client that clones the transport
client := &http.Client{
Transport: source.Transport,
}
// Assign the CheckRedirect function
client.CheckRedirect = checkRedirect
return client
}
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clientID string, raw string, light bool) error {
func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, tx *gorm.DB, clientID string, raw string, light bool) error {
u, err := url.Parse(raw)
if err != nil {
return err
@@ -1986,29 +1985,18 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
defer cancel()
// Prevents SSRF by allowing only public IPs
ok, err := utils.IsURLPrivate(ctx, u)
if err != nil {
return err
} else if ok {
return errors.New("private IP addresses are not allowed")
r := net.Resolver{}
ips, err := r.LookupIPAddr(ctx, u.Hostname())
if err != nil || len(ips) == 0 {
return fmt.Errorf("cannot resolve hostname")
}
// We need to check this on redirects too
client := httpClientWithCheckRedirect(s.httpClient, func(r *http.Request, via []*http.Request) error {
if len(via) >= 10 {
return errors.New("stopped after 10 redirects")
// Prevents SSRF by allowing only public IPs
for _, addr := range ips {
if utils.IsPrivateIP(addr.IP) {
return fmt.Errorf("private IP addresses are not allowed")
}
ok, err := utils.IsURLPrivate(r.Context(), r.URL)
if err != nil {
return err
} else if ok {
return errors.New("private IP addresses are not allowed")
}
return nil
})
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil)
if err != nil {
@@ -2017,7 +2005,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
req.Header.Set("User-Agent", "pocket-id/oidc-logo-fetcher")
req.Header.Set("Accept", "image/*")
resp, err := client.Do(req)
resp, err := s.httpClient.Do(req)
if err != nil {
return err
}
@@ -2029,7 +2017,7 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
const maxLogoSize int64 = 2 * 1024 * 1024 // 2MB
if resp.ContentLength > maxLogoSize {
return errLogoTooLarge
return fmt.Errorf("logo is too large")
}
// Prefer extension in path if supported
@@ -2049,83 +2037,48 @@ func (s *OidcService) downloadAndSaveLogoFromURL(parentCtx context.Context, clie
}
imagePath := path.Join("oidc-client-images", clientID+darkSuffix+"."+ext)
err = s.fileStorage.Save(ctx, imagePath, utils.NewLimitReader(resp.Body, maxLogoSize+1))
if errors.Is(err, utils.ErrSizeExceeded) {
return errLogoTooLarge
} else if err != nil {
if err := s.fileStorage.Save(ctx, imagePath, io.LimitReader(resp.Body, maxLogoSize+1)); err != nil {
return err
}
err = s.updateClientLogoType(ctx, clientID, ext, light)
if err != nil {
if err := s.updateClientLogoType(ctx, tx, clientID, ext, light); err != nil {
return err
}
return nil
}
func (s *OidcService) updateClientLogoType(ctx context.Context, clientID string, ext string, light bool) error {
func (s *OidcService) updateClientLogoType(ctx context.Context, tx *gorm.DB, clientID, ext string, light bool) error {
var darkSuffix string
if !light {
darkSuffix = "-dark"
}
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
// We need to acquire an update lock for the row to be locked, since we'll update it later
var client model.OidcClient
err := tx.
WithContext(ctx).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&client, "id = ?", clientID).
Error
if err != nil {
return fmt.Errorf("failed to look up client: %w", err)
if err := tx.WithContext(ctx).First(&client, "id = ?", clientID).Error; err != nil {
return err
}
var currentType *string
if light {
currentType = client.ImageType
client.ImageType = &ext
} else {
currentType = client.DarkImageType
client.DarkImageType = &ext
}
err = tx.
WithContext(ctx).
Save(&client).
Error
if err != nil {
return fmt.Errorf("failed to save updated client: %w", err)
}
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
// Storage operations must be executed outside of a transaction
if currentType != nil && *currentType != ext {
old := path.Join("oidc-client-images", client.ID+darkSuffix+"."+*currentType)
_ = s.fileStorage.Delete(ctx, old)
}
return nil
}
func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID string) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.
WithContext(ctx).
First(&provider, "oidc_client_id = ?", clientID).
Error
if err != nil {
return model.ScimServiceProvider{}, err
var column string
if light {
column = "image_type"
} else {
column = "dark_image_type"
}
return provider, nil
return tx.WithContext(ctx).
Model(&model.OidcClient{}).
Where("id = ?", clientID).
Update(column, ext).
Error
}

View File

@@ -8,10 +8,7 @@ import (
"crypto/sha256"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
"testing"
"time"
@@ -24,7 +21,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
"github.com/pocket-id/pocket-id/backend/internal/storage"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
@@ -148,7 +144,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
var err error
// Create a test database
db := testutils.NewDatabaseForTest(t)
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
// Create two JWKs for testing
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
@@ -542,435 +537,3 @@ func TestValidateCodeVerifier_Plain(t *testing.T) {
require.False(t, validateCodeVerifier("NOT!VALID", codeChallenge, true))
})
}
func TestOidcService_updateClientLogoType(t *testing.T) {
// Create a test database
db := testutils.NewDatabaseForTest(t)
// Create database storage
dbStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
// Init the OidcService
s := &OidcService{
db: db,
fileStorage: dbStorage,
}
// Create a test client
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
// Helper function to check if a file exists in storage
fileExists := func(t *testing.T, path string) bool {
t.Helper()
_, _, err := dbStorage.Open(t.Context(), path)
return err == nil
}
// Helper function to create a dummy file in storage
createDummyFile := func(t *testing.T, path string) {
t.Helper()
err := dbStorage.Save(t.Context(), path, strings.NewReader("dummy content"))
require.NoError(t, err)
}
t.Run("Updates light logo type for client without previous logo", func(t *testing.T) {
// Update the logo type
err := s.updateClientLogoType(t.Context(), client.ID, "png", true)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "png", *updatedClient.ImageType)
})
t.Run("Updates dark logo type for client without previous dark logo", func(t *testing.T) {
// Update the dark logo type
err := s.updateClientLogoType(t.Context(), client.ID, "jpg", false)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.DarkImageType)
assert.Equal(t, "jpg", *updatedClient.DarkImageType)
})
t.Run("Updates light logo type and deletes old file when type changes", func(t *testing.T) {
// Create the old PNG file in storage
oldPath := "oidc-client-images/" + client.ID + ".png"
createDummyFile(t, oldPath)
require.True(t, fileExists(t, oldPath), "Old file should exist before update")
// Client currently has a PNG logo, update to WEBP
err := s.updateClientLogoType(t.Context(), client.ID, "webp", true)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "webp", *updatedClient.ImageType)
// Old PNG file should be deleted
assert.False(t, fileExists(t, oldPath), "Old PNG file should have been deleted")
})
t.Run("Updates dark logo type and deletes old file when type changes", func(t *testing.T) {
// Create the old JPG dark file in storage
oldPath := "oidc-client-images/" + client.ID + "-dark.jpg"
createDummyFile(t, oldPath)
require.True(t, fileExists(t, oldPath), "Old dark file should exist before update")
// Client currently has a JPG dark logo, update to WEBP
err := s.updateClientLogoType(t.Context(), client.ID, "webp", false)
require.NoError(t, err)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.DarkImageType)
assert.Equal(t, "webp", *updatedClient.DarkImageType)
// Old JPG dark file should be deleted
assert.False(t, fileExists(t, oldPath), "Old JPG dark file should have been deleted")
})
t.Run("Does not delete file when type remains the same", func(t *testing.T) {
// Create the WEBP file in storage
webpPath := "oidc-client-images/" + client.ID + ".webp"
createDummyFile(t, webpPath)
require.True(t, fileExists(t, webpPath), "WEBP file should exist before update")
// Update to the same type (WEBP)
err := s.updateClientLogoType(t.Context(), client.ID, "webp", true)
require.NoError(t, err)
// Verify the client still has WEBP
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "webp", *updatedClient.ImageType)
// WEBP file should still exist since type didn't change
assert.True(t, fileExists(t, webpPath), "WEBP file should still exist")
})
t.Run("Returns error for non-existent client", func(t *testing.T) {
err := s.updateClientLogoType(t.Context(), "non-existent-client-id", "png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
})
}
func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
// Create a test database
db := testutils.NewDatabaseForTest(t)
// Create database storage
dbStorage, err := storage.NewDatabaseStorage(db)
require.NoError(t, err)
// Create a test client
client := model.OidcClient{
Name: "Test Client",
CallbackURLs: model.UrlList{"https://example.com/callback"},
}
err = db.Create(&client).Error
require.NoError(t, err)
// Helper function to check if a file exists in storage
fileExists := func(t *testing.T, path string) bool {
t.Helper()
_, _, err := dbStorage.Open(t.Context(), path)
return err == nil
}
// Helper function to get file content from storage
getFileContent := func(t *testing.T, path string) []byte {
t.Helper()
reader, _, err := dbStorage.Open(t.Context(), path)
require.NoError(t, err)
defer reader.Close()
content, err := io.ReadAll(reader)
require.NoError(t, err)
return content
}
t.Run("Successfully downloads and saves PNG logo from URL", func(t *testing.T) {
// Create mock PNG content
pngContent := []byte("fake-png-content")
// Create a mock HTTP response with headers
//nolint:bodyclose
pngResponse := testutils.NewMockResponse(http.StatusOK, string(pngContent))
pngResponse.Header.Set("Content-Type", "image/png")
// Create a mock HTTP client with responses
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
// Init the OidcService with mock HTTP client
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
// Download and save the logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo.png", true)
require.NoError(t, err)
// Verify the file was saved
logoPath := "oidc-client-images/" + client.ID + ".png"
require.True(t, fileExists(t, logoPath), "Logo file should exist in storage")
// Verify the content
savedContent := getFileContent(t, logoPath)
assert.Equal(t, pngContent, savedContent)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.ImageType)
assert.Equal(t, "png", *updatedClient.ImageType)
})
t.Run("Successfully downloads and saves dark logo", func(t *testing.T) {
// Create mock WEBP content
webpContent := []byte("fake-webp-content")
//nolint:bodyclose
webpResponse := testutils.NewMockResponse(http.StatusOK, string(webpContent))
webpResponse.Header.Set("Content-Type", "image/webp")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/dark-logo.webp": webpResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
// Download and save the dark logo
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/dark-logo.webp", false)
require.NoError(t, err)
// Verify the dark logo file was saved
darkLogoPath := "oidc-client-images/" + client.ID + "-dark.webp"
require.True(t, fileExists(t, darkLogoPath), "Dark logo file should exist in storage")
// Verify the content
savedContent := getFileContent(t, darkLogoPath)
assert.Equal(t, webpContent, savedContent)
// Verify the client was updated
var updatedClient model.OidcClient
err = db.First(&updatedClient, "id = ?", client.ID).Error
require.NoError(t, err)
require.NotNil(t, updatedClient.DarkImageType)
assert.Equal(t, "webp", *updatedClient.DarkImageType)
})
t.Run("Detects extension from URL path", func(t *testing.T) {
svgContent := []byte("<svg></svg>")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/icon.svg": testutils.NewMockResponse(http.StatusOK, string(svgContent)),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/icon.svg", true)
require.NoError(t, err)
// Verify SVG file was saved
logoPath := "oidc-client-images/" + client.ID + ".svg"
require.True(t, fileExists(t, logoPath), "SVG logo should exist")
})
t.Run("Detects extension from Content-Type when path has no extension", func(t *testing.T) {
jpgContent := []byte("fake-jpg-content")
//nolint:bodyclose
jpgResponse := testutils.NewMockResponse(http.StatusOK, string(jpgContent))
jpgResponse.Header.Set("Content-Type", "image/jpeg")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/logo": jpgResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/logo", true)
require.NoError(t, err)
// Verify JPG file was saved (jpeg extension is normalized to jpg)
logoPath := "oidc-client-images/" + client.ID + ".jpg"
require.True(t, fileExists(t, logoPath), "JPG logo should exist")
})
t.Run("Returns error for invalid URL", func(t *testing.T) {
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: &http.Client{},
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "://invalid-url", true)
require.Error(t, err)
})
t.Run("Returns error for non-200 status code", func(t *testing.T) {
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/not-found.png": testutils.NewMockResponse(http.StatusNotFound, "Not Found"),
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/not-found.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to fetch logo")
})
t.Run("Returns error for too large content", func(t *testing.T) {
// Create content larger than 2MB (maxLogoSize)
largeContent := strings.Repeat("x", 2<<20+100) // 2.1MB
//nolint:bodyclose
largeResponse := testutils.NewMockResponse(http.StatusOK, largeContent)
largeResponse.Header.Set("Content-Type", "image/png")
largeResponse.Header.Set("Content-Length", strconv.Itoa(len(largeContent)))
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/large.png": largeResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/large.png", true)
require.Error(t, err)
require.ErrorIs(t, err, errLogoTooLarge)
})
t.Run("Returns error for unsupported file type", func(t *testing.T) {
//nolint:bodyclose
textResponse := testutils.NewMockResponse(http.StatusOK, "text content")
textResponse.Header.Set("Content-Type", "text/plain")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/file.txt": textResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), client.ID, "https://example.com/file.txt", true)
require.Error(t, err)
var fileTypeErr *common.FileTypeNotSupportedError
require.ErrorAs(t, err, &fileTypeErr)
})
t.Run("Returns error for non-existent client", func(t *testing.T) {
//nolint:bodyclose
pngResponse := testutils.NewMockResponse(http.StatusOK, "content")
pngResponse.Header.Set("Content-Type", "image/png")
mockResponses := map[string]*http.Response{
//nolint:bodyclose
"https://example.com/logo.png": pngResponse,
}
httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{
Responses: mockResponses,
},
}
s := &OidcService{
db: db,
fileStorage: dbStorage,
httpClient: httpClient,
}
err := s.downloadAndSaveLogoFromURL(t.Context(), "non-existent-client-id", "https://example.com/logo.png", true)
require.Error(t, err)
require.ErrorContains(t, err, "failed to look up client")
})
}

View File

@@ -1,136 +0,0 @@
package service
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"gorm.io/gorm"
)
// ScimSchedulerService schedules and triggers periodic synchronization
// of SCIM service providers. Each provider is tracked independently,
// and sync operations are run at or after their scheduled time.
type ScimSchedulerService struct {
scimService *ScimService
providerSyncTime map[string]time.Time
mu sync.RWMutex
}
func NewScimSchedulerService(ctx context.Context, scimService *ScimService) (*ScimSchedulerService, error) {
s := &ScimSchedulerService{
scimService: scimService,
providerSyncTime: make(map[string]time.Time),
}
err := s.start(ctx)
return s, err
}
// ScheduleSync forces the given provider to be synced soon by
// moving its next scheduled time to 5 minutes from now.
func (s *ScimSchedulerService) ScheduleSync(providerID string) {
s.setSyncTime(providerID, 5*time.Minute)
}
// start initializes the scheduler and begins the synchronization loop.
// Syncs happen every hour by default, but ScheduleSync can be called to schedule a sync sooner.
func (s *ScimSchedulerService) start(ctx context.Context) error {
if err := s.refreshProviders(ctx); err != nil {
return err
}
go func() {
const (
syncCheckInterval = 5 * time.Second
providerRefreshDelay = time.Minute
)
ticker := time.NewTicker(syncCheckInterval)
defer ticker.Stop()
lastProviderRefresh := time.Now()
for {
select {
case <-ctx.Done():
return
// Runs every 5 seconds to check if any provider is due for sync
case <-ticker.C:
now := time.Now()
if now.Sub(lastProviderRefresh) >= providerRefreshDelay {
err := s.refreshProviders(ctx)
if err != nil {
slog.Error("Error refreshing SCIM service providers",
slog.Any("error", err),
)
} else {
lastProviderRefresh = now
}
}
var due []string
s.mu.RLock()
for providerID, syncTime := range s.providerSyncTime {
if !syncTime.After(now) {
due = append(due, providerID)
}
}
s.mu.RUnlock()
s.syncProviders(ctx, due)
}
}
}()
return nil
}
func (s *ScimSchedulerService) refreshProviders(ctx context.Context) error {
providers, err := s.scimService.ListServiceProviders(ctx)
if err != nil {
return err
}
inAHour := time.Now().Add(time.Hour)
s.mu.Lock()
for _, provider := range providers {
if _, exists := s.providerSyncTime[provider.ID]; !exists {
s.providerSyncTime[provider.ID] = inAHour
}
}
s.mu.Unlock()
return nil
}
func (s *ScimSchedulerService) syncProviders(ctx context.Context, providerIDs []string) {
for _, providerID := range providerIDs {
err := s.scimService.SyncServiceProvider(ctx, providerID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
// Remove the provider from the schedule if it no longer exists
s.mu.Lock()
delete(s.providerSyncTime, providerID)
s.mu.Unlock()
} else {
slog.Error("Error syncing SCIM client",
slog.String("provider_id", providerID),
slog.Any("error", err),
)
}
continue
}
// A successful sync schedules the next sync in an hour
s.setSyncTime(providerID, time.Hour)
}
}
func (s *ScimSchedulerService) setSyncTime(providerID string, t time.Duration) {
s.mu.Lock()
s.providerSyncTime[providerID] = time.Now().Add(t)
s.mu.Unlock()
}

View File

@@ -1,774 +0,0 @@
package service
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/dto"
"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"
"gorm.io/gorm"
)
const (
scimUserSchema = "urn:ietf:params:scim:schemas:core:2.0:User"
scimGroupSchema = "urn:ietf:params:scim:schemas:core:2.0:Group"
scimContentType = "application/scim+json"
)
const scimErrorBodyLimit = 4096
type scimSyncAction int
const (
scimActionNone scimSyncAction = iota
scimActionCreated
scimActionUpdated
scimActionDeleted
)
type scimSyncStats struct {
Created int
Updated int
Deleted int
}
// ScimService handles SCIM provisioning to external service providers.
type ScimService struct {
db *gorm.DB
httpClient *http.Client
}
func NewScimService(db *gorm.DB, httpClient *http.Client) *ScimService {
if httpClient == nil {
httpClient = &http.Client{Timeout: 20 * time.Second}
}
return &ScimService{db: db, httpClient: httpClient}
}
func (s *ScimService) GetServiceProvider(
ctx context.Context,
serviceProviderID string,
) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.WithContext(ctx).
Preload("OidcClient").
Preload("OidcClient.AllowedUserGroups").
First(&provider, "id = ?", serviceProviderID).
Error
if err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) ListServiceProviders(ctx context.Context) ([]model.ScimServiceProvider, error) {
var providers []model.ScimServiceProvider
err := s.db.WithContext(ctx).
Preload("OidcClient").
Find(&providers).
Error
if err != nil {
return nil, err
}
return providers, nil
}
func (s *ScimService) CreateServiceProvider(
ctx context.Context,
input *dto.ScimServiceProviderCreateDTO) (model.ScimServiceProvider, error) {
provider := model.ScimServiceProvider{
Endpoint: input.Endpoint,
Token: datatype.EncryptedString(input.Token),
OidcClientID: input.OidcClientID,
}
if err := s.db.WithContext(ctx).Create(&provider).Error; err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) UpdateServiceProvider(ctx context.Context,
serviceProviderID string,
input *dto.ScimServiceProviderCreateDTO,
) (model.ScimServiceProvider, error) {
var provider model.ScimServiceProvider
err := s.db.WithContext(ctx).
First(&provider, "id = ?", serviceProviderID).
Error
if err != nil {
return model.ScimServiceProvider{}, err
}
provider.Endpoint = input.Endpoint
provider.Token = datatype.EncryptedString(input.Token)
provider.OidcClientID = input.OidcClientID
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
return model.ScimServiceProvider{}, err
}
return provider, nil
}
func (s *ScimService) DeleteServiceProvider(ctx context.Context, serviceProviderID string) error {
return s.db.WithContext(ctx).
Delete(&model.ScimServiceProvider{}, "id = ?", serviceProviderID).
Error
}
func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID string) error {
start := time.Now()
provider, err := s.GetServiceProvider(ctx, serviceProviderID)
if err != nil {
return err
}
slog.InfoContext(ctx, "Syncing SCIM service provider",
slog.String("provider_id", provider.ID),
slog.String("oidc_client_id", provider.OidcClientID),
)
allowedGroupIDs := groupIDs(provider.OidcClient.AllowedUserGroups)
// Load users and groups that should be synced to the SCIM provider
groups, err := s.groupsForClient(ctx, provider.OidcClient, allowedGroupIDs)
if err != nil {
return err
}
users, err := s.usersForClient(ctx, provider.OidcClient, allowedGroupIDs)
if err != nil {
return err
}
// Load users and groups that already exist in the SCIM provider
userResources, err := listScimResources[dto.ScimUser](s, ctx, provider, "/Users")
if err != nil {
return err
}
groupResources, err := listScimResources[dto.ScimGroup](s, ctx, provider, "/Groups")
if err != nil {
return err
}
var errs []error
var userStats scimSyncStats
var groupStats scimSyncStats
// Sync users first, so that groups can reference them
if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
errs = append(errs, err)
userStats = stats
} else {
userStats = stats
}
stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
if err != nil {
errs = append(errs, err)
groupStats = stats
} else {
groupStats = stats
}
if len(errs) > 0 {
slog.WarnContext(ctx, "SCIM sync completed with errors",
slog.String("provider_id", provider.ID),
slog.Int("error_count", len(errs)),
slog.Int("users_created", userStats.Created),
slog.Int("users_updated", userStats.Updated),
slog.Int("users_deleted", userStats.Deleted),
slog.Int("groups_created", groupStats.Created),
slog.Int("groups_updated", groupStats.Updated),
slog.Int("groups_deleted", groupStats.Deleted),
slog.Duration("duration", time.Since(start)),
)
return errors.Join(errs...)
}
provider.LastSyncedAt = utils.Ptr(datatype.DateTime(time.Now()))
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
return err
}
slog.InfoContext(ctx, "SCIM sync completed",
slog.String("provider_id", provider.ID),
slog.Int("users_created", userStats.Created),
slog.Int("users_updated", userStats.Updated),
slog.Int("users_deleted", userStats.Deleted),
slog.Int("groups_created", groupStats.Created),
slog.Int("groups_updated", groupStats.Updated),
slog.Int("groups_deleted", groupStats.Deleted),
slog.Duration("duration", time.Since(start)),
)
return nil
}
func (s *ScimService) syncUsers(
ctx context.Context,
provider model.ScimServiceProvider,
users []model.User,
resourceList *dto.ScimListResponse[dto.ScimUser],
) (stats scimSyncStats, err error) {
var errs []error
// Update or create users
for _, u := range users {
existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
action, created, err := s.syncUser(ctx, provider, u, existing)
if created != nil && existing == nil {
resourceList.Resources = append(resourceList.Resources, *created)
}
if err != nil {
errs = append(errs, err)
continue
}
// Update stats based on action taken by syncUser
switch action {
case scimActionCreated:
stats.Created++
case scimActionUpdated:
stats.Updated++
case scimActionDeleted:
stats.Deleted++
case scimActionNone:
}
}
// Delete users that are present in SCIM provider but not locally.
userSet := make(map[string]struct{})
for _, u := range users {
userSet[u.ID] = struct{}{}
}
for _, r := range resourceList.Resources {
if _, ok := userSet[r.ExternalID]; !ok {
if err := s.deleteScimResource(ctx, provider, "/Users/"+url.PathEscape(r.ID)); err != nil {
errs = append(errs, err)
} else {
stats.Deleted++
}
}
}
return stats, errors.Join(errs...)
}
func (s *ScimService) syncGroups(
ctx context.Context,
provider model.ScimServiceProvider,
groups []model.UserGroup,
remoteGroups []dto.ScimGroup,
userResources []dto.ScimUser,
) (stats scimSyncStats, err error) {
var errs []error
// Update or create groups
for _, g := range groups {
existing := getResourceByExternalID[dto.ScimGroup](g.ID, remoteGroups)
action, err := s.syncGroup(ctx, provider, g, existing, userResources)
if err != nil {
errs = append(errs, err)
continue
}
// Update stats based on action taken by syncGroup
switch action {
case scimActionCreated:
stats.Created++
case scimActionUpdated:
stats.Updated++
case scimActionDeleted:
stats.Deleted++
case scimActionNone:
}
}
// Delete groups that are present in SCIM provider but not locally
groupSet := make(map[string]struct{})
for _, g := range groups {
groupSet[g.ID] = struct{}{}
}
for _, r := range remoteGroups {
if _, ok := groupSet[r.ExternalID]; !ok {
if err := s.deleteScimResource(ctx, provider, "/Groups/"+url.PathEscape(r.GetID())); err != nil {
errs = append(errs, err)
} else {
stats.Deleted++
}
}
}
return stats, errors.Join(errs...)
}
func (s *ScimService) syncUser(ctx context.Context,
provider model.ScimServiceProvider,
user model.User,
userResource *dto.ScimUser,
) (scimSyncAction, *dto.ScimUser, error) {
// If user is not allowed for the client, delete it from SCIM provider
if userResource != nil && !IsUserGroupAllowedToAuthorize(user, provider.OidcClient) {
return scimActionDeleted, nil, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Users/%s", url.PathEscape(userResource.ID)))
}
payload := dto.ScimUser{
ScimResourceData: dto.ScimResourceData{
Schemas: []string{scimUserSchema},
ExternalID: user.ID,
},
UserName: user.Username,
Name: &dto.ScimName{
GivenName: user.FirstName,
FamilyName: user.LastName,
},
Display: user.DisplayName,
Active: !user.Disabled,
}
if user.Email != nil {
payload.Emails = []dto.ScimEmail{{
Value: *user.Email,
Primary: true,
}}
}
// If the user exists on the SCIM provider, and it has been modified, update it
if userResource != nil {
if user.LastModified().Before(userResource.GetMeta().LastModified) {
return scimActionNone, nil, nil
}
path := fmt.Sprintf("/Users/%s", url.PathEscape(userResource.GetID()))
userResource, err := updateScimResource(s, ctx, provider, path, payload)
if err != nil {
return scimActionNone, nil, err
}
return scimActionUpdated, userResource, nil
}
// Otherwise, create a new SCIM user
userResource, err := createScimResource(s, ctx, provider, "/Users", payload)
if err != nil {
return scimActionNone, nil, err
}
return scimActionCreated, userResource, nil
}
func (s *ScimService) syncGroup(
ctx context.Context,
provider model.ScimServiceProvider,
group model.UserGroup,
groupResource *dto.ScimGroup,
userResources []dto.ScimUser,
) (scimSyncAction, error) {
// If group is not allowed for the client, delete it from SCIM provider
if groupResource != nil && !groupAllowedForClient(group.ID, provider.OidcClient) {
return scimActionDeleted, s.deleteScimResource(ctx, provider, fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID())))
}
// Prepare group members
members := make([]dto.ScimGroupMember, len(group.Users))
for i, user := range group.Users {
userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
if userResource == nil {
// Groups depend on user IDs already being provisioned
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
}
members[i] = dto.ScimGroupMember{
Value: userResource.GetID(),
}
}
groupPayload := dto.ScimGroup{
ScimResourceData: dto.ScimResourceData{
Schemas: []string{scimGroupSchema},
ExternalID: group.ID,
},
Display: group.FriendlyName,
Members: members,
}
// If the group exists on the SCIM provider, and it has been modified, update it
if groupResource != nil {
if group.LastModified().Before(groupResource.GetMeta().LastModified) {
return scimActionNone, nil
}
path := fmt.Sprintf("/Groups/%s", url.PathEscape(groupResource.GetID()))
_, err := updateScimResource(s, ctx, provider, path, groupPayload)
if err != nil {
return scimActionNone, err
}
return scimActionUpdated, nil
}
// Otherwise, create a new SCIM group
_, err := createScimResource(s, ctx, provider, "/Groups", groupPayload)
if err != nil {
return scimActionNone, err
}
return scimActionCreated, nil
}
func groupAllowedForClient(groupID string, client model.OidcClient) bool {
if !client.IsGroupRestricted {
return true
}
for _, allowedGroup := range client.AllowedUserGroups {
if allowedGroup.ID == groupID {
return true
}
}
return false
}
func groupIDs(groups []model.UserGroup) []string {
ids := make([]string, len(groups))
for i, g := range groups {
ids[i] = g.ID
}
return ids
}
func (s *ScimService) groupsForClient(
ctx context.Context,
client model.OidcClient,
allowedGroupIDs []string,
) ([]model.UserGroup, error) {
var groups []model.UserGroup
query := s.db.WithContext(ctx).Preload("Users").Model(&model.UserGroup{})
if client.IsGroupRestricted {
if len(allowedGroupIDs) == 0 {
return groups, nil
}
query = query.Where("id IN ?", allowedGroupIDs)
}
if err := query.Find(&groups).Error; err != nil {
return nil, err
}
return groups, nil
}
func (s *ScimService) usersForClient(
ctx context.Context,
client model.OidcClient,
allowedGroupIDs []string,
) ([]model.User, error) {
var users []model.User
query := s.db.WithContext(ctx).Model(&model.User{})
if client.IsGroupRestricted {
if len(allowedGroupIDs) == 0 {
return users, nil
}
query = query.
Joins("JOIN user_groups_users ON users.id = user_groups_users.user_id").
Where("user_groups_users.user_group_id IN ?", allowedGroupIDs).
Select("users.*").
Distinct()
}
query = query.Preload("UserGroups")
if err := query.Find(&users).Error; err != nil {
return nil, err
}
return users, nil
}
func getResourceByExternalID[T dto.ScimResource](externalID string, resource []T) *T {
for i := range resource {
if resource[i].GetExternalID() == externalID {
return &resource[i]
}
}
return nil
}
func listScimResources[T any](
s *ScimService,
ctx context.Context,
provider model.ScimServiceProvider,
path string,
) (result dto.ScimListResponse[T], err error) {
startIndex := 1
count := 1000
for {
// Use SCIM pagination to avoid missing resources on large providers
queryParams := map[string]string{
"startIndex": strconv.Itoa(startIndex),
"count": strconv.Itoa(count),
}
resp, err := s.scimRequest(ctx, provider, http.MethodGet, path, nil, queryParams)
if err != nil {
return dto.ScimListResponse[T]{}, err
}
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK); err != nil {
return dto.ScimListResponse[T]{}, err
}
var page dto.ScimListResponse[T]
if err := json.NewDecoder(resp.Body).Decode(&page); err != nil {
return dto.ScimListResponse[T]{}, fmt.Errorf("failed to decode SCIM list response: %w", err)
}
resp.Body.Close()
// Initialize metadata only once
if result.TotalResults == 0 {
result.TotalResults = page.TotalResults
}
result.Resources = append(result.Resources, page.Resources...)
// If we've fetched everything, stop
if len(result.Resources) >= page.TotalResults || len(page.Resources) == 0 {
break
}
startIndex += page.ItemsPerPage
}
result.ItemsPerPage = len(result.Resources)
return result, nil
}
func createScimResource[T dto.ScimResource](
s *ScimService,
ctx context.Context,
provider model.ScimServiceProvider,
path string, payload T) (*T, error) {
resp, err := s.scimRequest(ctx, provider, http.MethodPost, path, payload, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil {
return nil, err
}
var resource T
if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
return nil, fmt.Errorf("failed to decode SCIM create response: %w", err)
}
return &resource, nil
}
func updateScimResource[T dto.ScimResource](
s *ScimService,
ctx context.Context,
provider model.ScimServiceProvider,
path string,
payload T,
) (*T, error) {
resp, err := s.scimRequest(ctx, provider, http.MethodPut, path, payload, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if err := ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusCreated); err != nil {
return nil, err
}
var resource T
if err := json.NewDecoder(resp.Body).Decode(&resource); err != nil {
return nil, fmt.Errorf("failed to decode SCIM update response: %w", err)
}
return &resource, nil
}
func (s *ScimService) deleteScimResource(ctx context.Context, provider model.ScimServiceProvider, path string) error {
resp, err := s.scimRequest(ctx, provider, http.MethodDelete, path, nil, nil)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil
}
return ensureScimStatus(ctx, resp, provider, http.StatusOK, http.StatusNoContent)
}
func (s *ScimService) scimRequest(
ctx context.Context,
provider model.ScimServiceProvider,
method,
path string,
payload any,
queryParams map[string]string,
) (*http.Response, error) {
urlString, err := scimURL(provider.Endpoint, path, queryParams)
if err != nil {
return nil, err
}
var bodyBytes []byte
if payload != nil {
encoded, err := json.Marshal(payload)
if err != nil {
return nil, fmt.Errorf("failed to encode SCIM payload: %w", err)
}
bodyBytes = encoded
}
retryAttempts := 3
for attempt := 1; attempt <= retryAttempts; attempt++ {
var body io.Reader
if bodyBytes != nil {
body = bytes.NewReader(bodyBytes)
}
req, err := http.NewRequestWithContext(ctx, method, urlString, body)
if err != nil {
return nil, err
}
req.Header.Set("Accept", scimContentType)
if payload != nil {
req.Header.Set("Content-Type", scimContentType)
}
token := string(provider.Token)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
slog.Debug("Sending SCIM request",
slog.String("method", method),
slog.String("url", urlString),
slog.String("provider_id", provider.ID),
)
resp, err := s.httpClient.Do(req)
if err != nil {
return nil, err
}
// Only retry on 429 to avoid masking other errors
if resp.StatusCode != http.StatusTooManyRequests || attempt == retryAttempts {
return resp, nil
}
retryDelay := scimRetryDelay(resp.Header.Get("Retry-After"), attempt)
slog.WarnContext(ctx, "SCIM provider rate-limited, retrying",
slog.String("provider_id", provider.ID),
slog.String("method", method),
slog.String("url", urlString),
slog.Int("attempt", attempt),
slog.Duration("retry_after", retryDelay),
)
resp.Body.Close()
if err := utils.SleepWithContext(ctx, retryDelay); err != nil {
return nil, err
}
}
return nil, fmt.Errorf("scim request retry attempts exceeded")
}
func scimRetryDelay(retryAfter string, attempt int) time.Duration {
// Respect Retry-After when provided
if retryAfter != "" {
if seconds, err := strconv.Atoi(retryAfter); err == nil {
return time.Duration(seconds) * time.Second
}
if t, err := http.ParseTime(retryAfter); err == nil {
if delay := time.Until(t); delay > 0 {
return delay
}
}
}
// Exponential backoff otherwise
maxDelay := 10 * time.Second
delay := 500 * time.Millisecond * (time.Duration(1) << (attempt - 1)) //nolint:gosec // attempt is bounded 1-3
if delay > maxDelay {
return maxDelay
}
return delay
}
func scimURL(endpoint, p string, queryParams map[string]string) (string, error) {
u, err := url.Parse(endpoint)
if err != nil {
return "", fmt.Errorf("invalid scim endpoint: %w", err)
}
u.Path = path.Join(strings.TrimRight(u.Path, "/"), p)
q := u.Query()
for key, value := range queryParams {
q.Set(key, value)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func ensureScimStatus(
ctx context.Context,
resp *http.Response,
provider model.ScimServiceProvider,
allowedStatuses ...int) error {
for _, status := range allowedStatuses {
if resp.StatusCode == status {
return nil
}
}
body := readScimErrorBody(resp.Body)
slog.ErrorContext(ctx, "SCIM request failed",
slog.String("provider_id", provider.ID),
slog.String("method", resp.Request.Method),
slog.String("url", resp.Request.URL.String()),
slog.Int("status", resp.StatusCode),
slog.String("response_body", body),
)
return fmt.Errorf("scim request failed with status %d: %s", resp.StatusCode, body)
}
func readScimErrorBody(body io.Reader) string {
payload, err := io.ReadAll(io.LimitReader(body, scimErrorBodyLimit))
if err != nil {
return ""
}
return strings.TrimSpace(string(payload))
}

View File

@@ -3,9 +3,7 @@ package service
import (
"context"
"errors"
"time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -55,7 +53,6 @@ func (s *UserGroupService) getInternal(ctx context.Context, id string, tx *gorm.
Where("id = ?", id).
Preload("CustomClaims").
Preload("Users").
Preload("AllowedOidcClients").
First(&group).
Error
return group, err
@@ -153,7 +150,6 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
group.Name = input.Name
group.FriendlyName = input.FriendlyName
group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx.
WithContext(ctx).
@@ -217,8 +213,6 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
}
// Save the updated group
group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx.
WithContext(ctx).
Save(&group).
@@ -254,54 +248,3 @@ func (s *UserGroupService) GetUserCountOfGroup(ctx context.Context, id string) (
Count()
return count, nil
}
func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id string, input dto.UserGroupUpdateAllowedOidcClientsDto) (group model.UserGroup, err error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
group, err = s.getInternal(ctx, id, tx)
if err != nil {
return model.UserGroup{}, err
}
// Fetch the clients based on the client IDs
var clients []model.OidcClient
if len(input.OidcClientIDs) > 0 {
err = tx.
WithContext(ctx).
Where("id IN (?)", input.OidcClientIDs).
Find(&clients).
Error
if err != nil {
return model.UserGroup{}, err
}
}
// Replace the current clients with the new set of clients
err = tx.
WithContext(ctx).
Model(&group).
Association("AllowedOidcClients").
Replace(clients)
if err != nil {
return model.UserGroup{}, err
}
// Save the updated group
err = tx.
WithContext(ctx).
Save(&group).
Error
if err != nil {
return model.UserGroup{}, err
}
err = tx.Commit().Error
if err != nil {
return model.UserGroup{}, err
}
return group, nil
}

View File

@@ -17,7 +17,6 @@ import (
"github.com/google/uuid"
"go.opentelemetry.io/otel/trace"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/dto"
@@ -102,10 +101,9 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
profilePicturePath := path.Join("profile-pictures", userID+".png")
// Try custom profile picture
file, size, err := s.fileStorage.Open(ctx, profilePicturePath)
if err == nil {
if file, size, err := s.fileStorage.Open(ctx, profilePicturePath); err == nil {
return file, size, nil
} else if !errors.Is(err, fs.ErrNotExist) {
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, 0, err
}
@@ -122,10 +120,9 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
// Try cached default for initials
defaultPicturePath := path.Join("profile-pictures", "defaults", user.Initials()+".png")
file, size, err = s.fileStorage.Open(ctx, defaultPicturePath)
if err == nil {
if file, size, err := s.fileStorage.Open(ctx, defaultPicturePath); err == nil {
return file, size, nil
} else if !errors.Is(err, fs.ErrNotExist) {
} else if err != nil && !errors.Is(err, fs.ErrNotExist) {
return nil, 0, err
}
@@ -136,13 +133,12 @@ func (s *UserService) GetProfilePicture(ctx context.Context, userID string) (io.
}
// Save the default picture for future use (in a goroutine to avoid blocking)
//nolint:contextcheck
defaultPictureBytes := defaultPicture.Bytes()
//nolint:contextcheck
go func() {
// Use bytes.NewReader because we need an io.ReadSeeker
rErr := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes))
if rErr != nil {
slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", rErr))
if err := s.fileStorage.Save(context.Background(), defaultPicturePath, bytes.NewReader(defaultPictureBytes)); err != nil {
slog.Error("Failed to cache default profile picture", slog.String("initials", user.Initials()), slog.Any("error", err))
}
}()
@@ -163,7 +159,7 @@ func (s *UserService) GetUserGroups(ctx context.Context, userID string) ([]model
return user.UserGroups, nil
}
func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, file io.ReadSeeker) error {
func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, file io.Reader) error {
// Validate the user ID to prevent directory traversal
err := uuid.Validate(userID)
if err != nil {
@@ -186,30 +182,17 @@ func (s *UserService) UpdateProfilePicture(ctx context.Context, userID string, f
}
func (s *UserService) DeleteUser(ctx context.Context, userID string, allowLdapDelete bool) error {
err := s.db.Transaction(func(tx *gorm.DB) error {
return s.deleteUserInternal(ctx, tx, userID, allowLdapDelete)
return s.db.Transaction(func(tx *gorm.DB) error {
return s.deleteUserInternal(ctx, userID, allowLdapDelete, tx)
})
if err != nil {
return fmt.Errorf("failed to delete user '%s': %w", userID, err)
}
// Storage operations must be executed outside of a transaction
profilePicturePath := path.Join("profile-pictures", userID+".png")
err = s.fileStorage.Delete(ctx, profilePicturePath)
if err != nil && !storage.IsNotExist(err) {
return fmt.Errorf("failed to delete profile picture for user '%s': %w", userID, err)
}
return nil
}
func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userID string, allowLdapDelete bool) error {
func (s *UserService) deleteUserInternal(ctx context.Context, userID string, allowLdapDelete bool, tx *gorm.DB) error {
var user model.User
err := tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if err != nil {
@@ -221,6 +204,11 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
return &common.LdapUserUpdateError{}
}
profilePicturePath := path.Join("profile-pictures", userID+".png")
if err := s.fileStorage.Delete(ctx, profilePicturePath); err != nil {
return err
}
err = tx.WithContext(ctx).Delete(&user).Error
if err != nil {
return fmt.Errorf("failed to delete user: %w", err)
@@ -253,18 +241,6 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
return model.User{}, &common.UserEmailNotSetError{}
}
var userGroups []model.UserGroup
if len(input.UserGroupIds) > 0 {
err := tx.
WithContext(ctx).
Where("id IN ?", input.UserGroupIds).
Find(&userGroups).
Error
if err != nil {
return model.User{}, err
}
}
user := model.User{
FirstName: input.FirstName,
LastName: input.LastName,
@@ -274,7 +250,6 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
IsAdmin: input.IsAdmin,
Locale: input.Locale,
Disabled: input.Disabled,
UserGroups: userGroups,
}
if input.LdapID != "" {
user.LdapID = &input.LdapID
@@ -298,13 +273,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
// Apply default groups and claims for new non-LDAP users
if !isLdapSync {
if len(input.UserGroupIds) == 0 {
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
if err := s.applySignupDefaults(ctx, &user, tx); err != nil {
return model.User{}, err
}
}
@@ -312,51 +281,34 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
return user, nil
}
func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User, tx *gorm.DB) error {
func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
// Apply default user groups
var groupIDs []string
v := config.SignupDefaultUserGroupIDs.Value
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &groupIDs)
if err != nil {
if v := config.SignupDefaultUserGroupIDs.Value; v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &groupIDs); err != nil {
return fmt.Errorf("invalid SignupDefaultUserGroupIDs JSON: %w", err)
}
if len(groupIDs) > 0 {
var groups []model.UserGroup
err = tx.WithContext(ctx).
Where("id IN ?", groupIDs).
Find(&groups).
Error
if err != nil {
if err := tx.WithContext(ctx).Where("id IN ?", groupIDs).Find(&groups).Error; err != nil {
return fmt.Errorf("failed to find default user groups: %w", err)
}
err = tx.WithContext(ctx).
Model(user).
Association("UserGroups").
Replace(groups)
if err != nil {
if err := tx.WithContext(ctx).Model(user).Association("UserGroups").Replace(groups); err != nil {
return fmt.Errorf("failed to associate default user groups: %w", err)
}
}
}
return nil
}
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
config := s.appConfigService.GetDbConfig()
// Apply default custom claims
var claims []dto.CustomClaimCreateDto
v := config.SignupDefaultCustomClaims.Value
if v != "" && v != "[]" {
err := json.Unmarshal([]byte(v), &claims)
if err != nil {
if v := config.SignupDefaultCustomClaims.Value; v != "" && v != "[]" {
if err := json.Unmarshal([]byte(v), &claims); err != nil {
return fmt.Errorf("invalid SignupDefaultCustomClaims JSON: %w", err)
}
if len(claims) > 0 {
_, err = s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx)
if err != nil {
if _, err := s.customClaimService.updateCustomClaimsInternal(ctx, UserID, user.ID, claims, tx); err != nil {
return fmt.Errorf("failed to apply default custom claims: %w", err)
}
}
@@ -393,7 +345,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
err := tx.
WithContext(ctx).
Where("id = ?", userID).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&user).
Error
if err != nil {
@@ -426,8 +377,6 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
}
}
user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx.
WithContext(ctx).
Save(&user).
@@ -456,36 +405,30 @@ func (s *UserService) RequestOneTimeAccessEmailAsAdmin(ctx context.Context, user
return &common.OneTimeAccessDisabledError{}
}
_, err := s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl, true)
return err
return s.requestOneTimeAccessEmailInternal(ctx, userID, "", ttl)
}
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) (string, error) {
func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context.Context, userID, redirectPath string) error {
isDisabled := !s.appConfigService.GetDbConfig().EmailOneTimeAccessAsUnauthenticatedEnabled.IsTrue()
if isDisabled {
return "", &common.OneTimeAccessDisabledError{}
return &common.OneTimeAccessDisabledError{}
}
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
// Do not return error if user not found to prevent email enumeration
return "", nil
} else if err != nil {
return "", err
}
deviceToken, err := s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute, true)
if err != nil {
return "", err
} else if deviceToken == nil {
return "", errors.New("device token expected but not returned")
// Do not return error if user not found to prevent email enumeration
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
return *deviceToken, nil
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
}
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration, withDeviceToken bool) (*string, error) {
func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, userID, redirectPath string, ttl time.Duration) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -493,20 +436,21 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
user, err := s.GetUser(ctx, userID)
if err != nil {
return nil, err
return err
}
if user.Email == nil {
return nil, &common.UserEmailNotSetError{}
return &common.UserEmailNotSetError{}
}
oneTimeAccessToken, deviceToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, withDeviceToken, tx)
oneTimeAccessToken, err := s.createOneTimeAccessTokenInternal(ctx, user.ID, ttl, tx)
if err != nil {
return nil, err
return err
}
err = tx.Commit().Error
if err != nil {
return nil, err
return err
}
// We use a background context here as this is running in a goroutine
@@ -539,29 +483,28 @@ func (s *UserService) requestOneTimeAccessEmailInternal(ctx context.Context, use
}
}()
return deviceToken, nil
return nil
}
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
return token, err
func (s *UserService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (string, error) {
return s.createOneTimeAccessTokenInternal(ctx, userID, ttl, s.db)
}
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl, withDeviceToken)
func (s *UserService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, tx *gorm.DB) (string, error) {
oneTimeAccessToken, err := NewOneTimeAccessToken(userID, ttl)
if err != nil {
return "", nil, err
return "", err
}
err = tx.WithContext(ctx).Create(oneTimeAccessToken).Error
if err != nil {
return "", nil, err
return "", err
}
return oneTimeAccessToken.Token, oneTimeAccessToken.DeviceToken, nil
return oneTimeAccessToken.Token, nil
}
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, deviceToken, ipAddress, userAgent string) (model.User, string, error) {
func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token string, ipAddress, userAgent string) (model.User, string, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -570,9 +513,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, dev
var oneTimeAccessToken model.OneTimeAccessToken
err := tx.
WithContext(ctx).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).
Preload("User").
Clauses(clause.Locking{Strength: "UPDATE"}).
Where("token = ? AND expires_at > ?", token, datatype.DateTime(time.Now())).Preload("User").
First(&oneTimeAccessToken).
Error
if err != nil {
@@ -581,10 +522,6 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token, dev
}
return model.User{}, "", err
}
if oneTimeAccessToken.DeviceToken != nil && deviceToken != *oneTimeAccessToken.DeviceToken {
return model.User{}, "", &common.DeviceCodeInvalid{}
}
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
if err != nil {
return model.User{}, "", err
@@ -648,16 +585,6 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return model.User{}, err
}
// Update the UpdatedAt field for all affected groups
now := time.Now()
for _, group := range groups {
group.UpdatedAt = utils.Ptr(datatype.DateTime(now))
err = tx.WithContext(ctx).Save(&group).Error
if err != nil {
return model.User{}, err
}
}
err = tx.Commit().Error
if err != nil {
return model.User{}, err
@@ -752,7 +679,7 @@ func (s *UserService) ResetProfilePicture(ctx context.Context, userID string) er
return nil
}
func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, userID string) error {
func (s *UserService) disableUserInternal(ctx context.Context, userID string, tx *gorm.DB) error {
return tx.
WithContext(ctx).
Model(&model.User{}).
@@ -761,22 +688,12 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
Error
}
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int, userGroupIDs []string) (model.SignupToken, error) {
func (s *UserService) CreateSignupToken(ctx context.Context, ttl time.Duration, usageLimit int) (model.SignupToken, error) {
signupToken, err := NewSignupToken(ttl, usageLimit)
if err != nil {
return model.SignupToken{}, err
}
var userGroups []model.UserGroup
err = s.db.WithContext(ctx).
Where("id IN ?", userGroupIDs).
Find(&userGroups).
Error
if err != nil {
return model.SignupToken{}, err
}
signupToken.UserGroups = userGroups
err = s.db.WithContext(ctx).Create(signupToken).Error
if err != nil {
return model.SignupToken{}, err
@@ -799,13 +716,10 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
}
var signupToken model.SignupToken
var userGroupIDs []string
if tokenProvided {
err := tx.
WithContext(ctx).
Preload("UserGroups").
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&signupToken).
Error
if err != nil {
@@ -818,19 +732,14 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
if !signupToken.IsValid() {
return model.User{}, "", &common.TokenInvalidOrExpiredError{}
}
for _, group := range signupToken.UserGroups {
userGroupIDs = append(userGroupIDs, group.ID)
}
}
userToCreate := dto.UserCreateDto{
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
UserGroupIds: userGroupIDs,
Username: signupData.Username,
Email: signupData.Email,
FirstName: signupData.FirstName,
LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
}
user, err := s.createUserInternal(ctx, userToCreate, false, tx)
@@ -871,7 +780,7 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
func (s *UserService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
var tokens []model.SignupToken
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
query := s.db.WithContext(ctx).Model(&model.SignupToken{})
pagination, err := utils.PaginateFilterAndSort(listRequestOptions, query, &tokens)
return tokens, pagination, err
@@ -881,33 +790,23 @@ func (s *UserService) DeleteSignupToken(ctx context.Context, tokenID string) err
return s.db.WithContext(ctx).Delete(&model.SignupToken{}, "id = ?", tokenID).Error
}
func NewOneTimeAccessToken(userID string, ttl time.Duration, withDeviceToken bool) (*model.OneTimeAccessToken, error) {
func NewOneTimeAccessToken(userID string, ttl time.Duration) (*model.OneTimeAccessToken, error) {
// If expires at is less than 15 minutes, use a 6-character token instead of 16
tokenLength := 16
if ttl <= 15*time.Minute {
tokenLength = 6
}
token, err := utils.GenerateRandomUnambiguousString(tokenLength)
randomString, err := utils.GenerateRandomAlphanumericString(tokenLength)
if err != nil {
return nil, err
}
var deviceToken *string
if withDeviceToken {
dt, err := utils.GenerateRandomAlphanumericString(16)
if err != nil {
return nil, err
}
deviceToken = &dt
}
now := time.Now().Round(time.Second)
o := &model.OneTimeAccessToken{
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: token,
DeviceToken: deviceToken,
UserID: userID,
ExpiresAt: datatype.DateTime(now.Add(ttl)),
Token: randomString,
}
return o, nil

View File

@@ -58,7 +58,7 @@ func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
}
if payload.TagName == "" {
return "", errors.New("GitHub API returned empty tag name")
return "", fmt.Errorf("GitHub API returned empty tag name")
}
return strings.TrimPrefix(payload.TagName, "v"), nil

View File

@@ -2,8 +2,6 @@ package service
import (
"context"
"encoding/hex"
"errors"
"fmt"
"net/http"
"time"
@@ -116,7 +114,7 @@ func (s *WebAuthnService) BeginRegistration(ctx context.Context, userID string)
}, nil
}
func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID string, userID string, r *http.Request, ipAddress string) (model.WebauthnCredential, error) {
func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID, userID string, r *http.Request) (model.WebauthnCredential, error) {
tx := s.db.Begin()
defer func() {
tx.Rollback()
@@ -175,9 +173,6 @@ func (s *WebAuthnService) VerifyRegistration(ctx context.Context, sessionID stri
return model.WebauthnCredential{}, fmt.Errorf("failed to store WebAuthn credential: %w", err)
}
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.ID), "passkeyName": passkeyName}
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyAdded, ipAddress, r.UserAgent(), userID, auditLogData, tx)
err = tx.Commit().Error
if err != nil {
return model.WebauthnCredential{}, fmt.Errorf("failed to commit transaction: %w", err)
@@ -293,30 +288,16 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([
return credentials, nil
}
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string) error {
tx := s.db.Begin()
defer func() {
tx.Rollback()
}()
credential := &model.WebauthnCredential{}
err := tx.
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID, credentialID string) error {
err := s.db.
WithContext(ctx).
Clauses(clause.Returning{}).
Delete(credential, "id = ? AND user_id = ?", credentialID, userID).
Where("id = ? AND user_id = ?", credentialID, userID).
Delete(&model.WebauthnCredential{}).
Error
if err != nil {
return fmt.Errorf("failed to delete record: %w", err)
}
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
err = tx.Commit().Error
if err != nil {
return fmt.Errorf("failed to commit transaction: %w", err)
}
return nil
}
@@ -372,7 +353,7 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context
userID, ok := token.Subject()
if !ok {
return "", errors.New("access token does not contain user ID")
return "", fmt.Errorf("access token does not contain user ID")
}
// Check if token is issued less than a minute ago

View File

@@ -1,226 +0,0 @@
package storage
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
var TypeDatabase = "database"
type databaseStorage struct {
db *gorm.DB
}
// NewDatabaseStorage creates a new database storage provider
func NewDatabaseStorage(db *gorm.DB) (FileStorage, error) {
if db == nil {
return nil, errors.New("database connection is required")
}
return &databaseStorage{db: db}, nil
}
func (s *databaseStorage) Type() string {
return TypeDatabase
}
func (s *databaseStorage) Save(ctx context.Context, relativePath string, data io.Reader) error {
// Normalize the path
relativePath = filepath.ToSlash(filepath.Clean(relativePath))
// Read all data into memory
b, err := io.ReadAll(data)
if err != nil {
return fmt.Errorf("failed to read data: %w", err)
}
now := datatype.DateTime(time.Now())
storage := model.Storage{
Path: relativePath,
Data: b,
Size: int64(len(b)),
ModTime: now,
CreatedAt: now,
}
// Use upsert: insert or update on conflict
result := s.db.
WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "path"}},
DoUpdates: clause.AssignmentColumns([]string{"data", "size", "mod_time"}),
}).
Create(&storage)
if result.Error != nil {
return fmt.Errorf("failed to save file to database: %w", result.Error)
}
return nil
}
func (s *databaseStorage) Open(ctx context.Context, relativePath string) (io.ReadCloser, int64, error) {
relativePath = filepath.ToSlash(filepath.Clean(relativePath))
var storage model.Storage
result := s.db.
WithContext(ctx).
Where("path = ?", relativePath).
First(&storage)
if result.Error != nil {
if errors.Is(result.Error, gorm.ErrRecordNotFound) {
return nil, 0, os.ErrNotExist
}
return nil, 0, fmt.Errorf("failed to read file from database: %w", result.Error)
}
reader := io.NopCloser(bytes.NewReader(storage.Data))
return reader, storage.Size, nil
}
func (s *databaseStorage) Delete(ctx context.Context, relativePath string) error {
relativePath = filepath.ToSlash(filepath.Clean(relativePath))
result := s.db.
WithContext(ctx).
Where("path = ?", relativePath).
Delete(&model.Storage{})
if result.Error != nil {
return fmt.Errorf("failed to delete file from database: %w", result.Error)
}
return nil
}
func (s *databaseStorage) DeleteAll(ctx context.Context, prefix string) error {
prefix = filepath.ToSlash(filepath.Clean(prefix))
// If empty prefix, delete all
if isRootPath(prefix) {
result := s.db.
WithContext(ctx).
Where("1 = 1"). // Delete everything
Delete(&model.Storage{})
if result.Error != nil {
return fmt.Errorf("failed to delete all files from database: %w", result.Error)
}
return nil
}
// Ensure prefix ends with / for proper prefix matching
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
query := s.db.WithContext(ctx)
query = addPathPrefixClause(s.db.Name(), query, prefix)
result := query.Delete(&model.Storage{})
if result.Error != nil {
return fmt.Errorf("failed to delete files with prefix '%s' from database: %w", prefix, result.Error)
}
return nil
}
func (s *databaseStorage) List(ctx context.Context, prefix string) ([]ObjectInfo, error) {
prefix = filepath.ToSlash(filepath.Clean(prefix))
var storageItems []model.Storage
query := s.db.WithContext(ctx)
if !isRootPath(prefix) {
// Ensure prefix matching
if !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
query = addPathPrefixClause(s.db.Name(), query, prefix)
}
result := query.
Select("path", "size", "mod_time").
Find(&storageItems)
if result.Error != nil {
return nil, fmt.Errorf("failed to list files from database: %w", result.Error)
}
objects := make([]ObjectInfo, 0, len(storageItems))
for _, item := range storageItems {
// Filter out directory-like paths (those that contain additional slashes after the prefix)
relativePath := strings.TrimPrefix(item.Path, prefix)
if strings.ContainsRune(relativePath, '/') {
continue
}
objects = append(objects, ObjectInfo{
Path: item.Path,
Size: item.Size,
ModTime: time.Time(item.ModTime),
})
}
return objects, nil
}
func (s *databaseStorage) Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error {
root = filepath.ToSlash(filepath.Clean(root))
var storageItems []model.Storage
query := s.db.WithContext(ctx)
if !isRootPath(root) {
// Ensure root matching
if !strings.HasSuffix(root, "/") {
root += "/"
}
query = addPathPrefixClause(s.db.Name(), query, root)
}
result := query.
Select("path", "size", "mod_time").
Find(&storageItems)
if result.Error != nil {
return fmt.Errorf("failed to walk files from database: %w", result.Error)
}
for _, item := range storageItems {
err := fn(ObjectInfo{
Path: item.Path,
Size: item.Size,
ModTime: time.Time(item.ModTime),
})
if err != nil {
return err
}
}
return nil
}
func isRootPath(path string) bool {
return path == "" || path == "/" || path == "."
}
func addPathPrefixClause(dialect string, query *gorm.DB, prefix string) *gorm.DB {
// In SQLite, we use "GLOB" which can use the index
switch dialect {
case "sqlite":
return query.Where("path GLOB ?", prefix+"*")
case "postgres":
return query.Where("path LIKE ?", prefix+"%")
default:
// Indicates a development-time error
panic(fmt.Errorf("unsupported database dialect: %s", dialect))
}
}

View File

@@ -1,148 +0,0 @@
package storage
import (
"bytes"
"context"
"io"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
testingutil "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
)
func TestDatabaseStorageOperations(t *testing.T) {
ctx := context.Background()
db := testingutil.NewDatabaseForTest(t)
store, err := NewDatabaseStorage(db)
require.NoError(t, err)
t.Run("type should be database", func(t *testing.T) {
assert.Equal(t, TypeDatabase, store.Type())
})
t.Run("save, open and list files", func(t *testing.T) {
err := store.Save(ctx, "images/logo.png", bytes.NewBufferString("logo-data"))
require.NoError(t, err)
reader, size, err := store.Open(ctx, "images/logo.png")
require.NoError(t, err)
defer reader.Close()
contents, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, []byte("logo-data"), contents)
assert.Equal(t, int64(len(contents)), size)
err = store.Save(ctx, "images/nested/child.txt", bytes.NewBufferString("child"))
require.NoError(t, err)
files, err := store.List(ctx, "images")
require.NoError(t, err)
require.Len(t, files, 1)
assert.Equal(t, "images/logo.png", files[0].Path)
assert.Equal(t, int64(len("logo-data")), files[0].Size)
})
t.Run("save should update existing file", func(t *testing.T) {
err := store.Save(ctx, "test/update.txt", bytes.NewBufferString("original"))
require.NoError(t, err)
err = store.Save(ctx, "test/update.txt", bytes.NewBufferString("updated"))
require.NoError(t, err)
reader, size, err := store.Open(ctx, "test/update.txt")
require.NoError(t, err)
defer reader.Close()
contents, err := io.ReadAll(reader)
require.NoError(t, err)
assert.Equal(t, []byte("updated"), contents)
assert.Equal(t, int64(len("updated")), size)
})
t.Run("delete files individually", func(t *testing.T) {
err := store.Save(ctx, "images/delete-me.txt", bytes.NewBufferString("temp"))
require.NoError(t, err)
require.NoError(t, store.Delete(ctx, "images/delete-me.txt"))
_, _, err = store.Open(ctx, "images/delete-me.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
})
t.Run("delete missing file should not error", func(t *testing.T) {
require.NoError(t, store.Delete(ctx, "images/missing.txt"))
})
t.Run("delete all files", func(t *testing.T) {
require.NoError(t, store.Save(ctx, "cleanup/a.txt", bytes.NewBufferString("a")))
require.NoError(t, store.Save(ctx, "cleanup/b.txt", bytes.NewBufferString("b")))
require.NoError(t, store.Save(ctx, "cleanup/nested/c.txt", bytes.NewBufferString("c")))
require.NoError(t, store.DeleteAll(ctx, "/"))
_, _, err := store.Open(ctx, "cleanup/a.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
_, _, err = store.Open(ctx, "cleanup/b.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
_, _, err = store.Open(ctx, "cleanup/nested/c.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
})
t.Run("delete all files under a prefix", func(t *testing.T) {
require.NoError(t, store.Save(ctx, "cleanup/a.txt", bytes.NewBufferString("a")))
require.NoError(t, store.Save(ctx, "cleanup/b.txt", bytes.NewBufferString("b")))
require.NoError(t, store.Save(ctx, "cleanup/nested/c.txt", bytes.NewBufferString("c")))
require.NoError(t, store.DeleteAll(ctx, "cleanup"))
_, _, err := store.Open(ctx, "cleanup/a.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
_, _, err = store.Open(ctx, "cleanup/b.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
_, _, err = store.Open(ctx, "cleanup/nested/c.txt")
require.Error(t, err)
assert.True(t, IsNotExist(err))
})
t.Run("walk files", func(t *testing.T) {
require.NoError(t, store.Save(ctx, "walk/file1.txt", bytes.NewBufferString("1")))
require.NoError(t, store.Save(ctx, "walk/file2.txt", bytes.NewBufferString("2")))
require.NoError(t, store.Save(ctx, "walk/nested/file3.txt", bytes.NewBufferString("3")))
var paths []string
err := store.Walk(ctx, "walk", func(info ObjectInfo) error {
paths = append(paths, info.Path)
return nil
})
require.NoError(t, err)
assert.Len(t, paths, 3)
assert.Contains(t, paths, "walk/file1.txt")
assert.Contains(t, paths, "walk/file2.txt")
assert.Contains(t, paths, "walk/nested/file3.txt")
})
}
func TestNewDatabaseStorage(t *testing.T) {
t.Run("should return error with nil database", func(t *testing.T) {
_, err := NewDatabaseStorage(nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "database connection is required")
})
t.Run("should create storage with valid database", func(t *testing.T) {
db := testingutil.NewDatabaseForTest(t)
store, err := NewDatabaseStorage(db)
require.NoError(t, err)
assert.NotNil(t, store)
})
}

View File

@@ -18,14 +18,13 @@ import (
)
type S3Config struct {
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
ForcePathStyle bool
DisableDefaultIntegrityChecks bool
Root string
Bucket string
Region string
Endpoint string
AccessKeyID string
SecretAccessKey string
ForcePathStyle bool
Root string
}
type s3Storage struct {
@@ -45,10 +44,6 @@ func NewS3Storage(ctx context.Context, cfg S3Config) (FileStorage, error) {
o.BaseEndpoint = aws.String(cfg.Endpoint)
}
o.UsePathStyle = cfg.ForcePathStyle
if cfg.DisableDefaultIntegrityChecks {
o.RequestChecksumCalculation = aws.RequestChecksumCalculationWhenRequired
o.ResponseChecksumValidation = aws.ResponseChecksumValidationWhenRequired
}
})
return &s3Storage{

View File

@@ -8,7 +8,7 @@ import (
)
var (
TypeFileSystem = "filesystem"
TypeFileSystem = "fs"
TypeS3 = "s3"
)

View File

@@ -1,199 +0,0 @@
package utils
import (
"net"
"net/url"
"path"
"regexp"
"strings"
)
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
//
// The authorization server MUST allow any port to be specified at the
// time of the request for loopback IP redirect URIs, to accommodate
// clients that obtain an available ephemeral port from the operating
// system at the time of the request.
loopbackRedirect := ""
u, _ := url.Parse(inputCallbackURL)
if u != nil && u.Scheme == "http" {
host := u.Hostname()
ip := net.ParseIP(host)
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
loopbackRedirect = u.String()
u.Host = host
inputCallbackURL = u.String()
}
}
for _, pattern := range urls {
matches, err := matchCallbackURL(pattern, inputCallbackURL)
if err != nil {
return "", err
} else if !matches {
continue
}
if loopbackRedirect != "" {
return loopbackRedirect, nil
}
return inputCallbackURL, nil
}
return "", nil
}
// matchCallbackURL checks if the input callback URL matches the given pattern.
// It supports wildcard matching for paths and query parameters.
//
// The base URL (scheme, userinfo, host, port) and query parameters supports single '*' wildcards only,
// while the path supports both single '*' and double '**' wildcards.
func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, err error) {
if pattern == inputCallbackURL || pattern == "*" {
return true, nil
}
// Strip fragment part
// The endpoint URI MUST NOT include a fragment component.
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
pattern, _, _ = strings.Cut(pattern, "#")
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
// Store and strip query part
var patternQuery url.Values
if i := strings.Index(pattern, "?"); i >= 0 {
patternQuery, err = url.ParseQuery(pattern[i+1:])
if err != nil {
return false, err
}
pattern = pattern[:i]
}
var inputQuery url.Values
if i := strings.Index(inputCallbackURL, "?"); i >= 0 {
inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:])
if err != nil {
return false, err
}
inputCallbackURL = inputCallbackURL[:i]
}
// Split both pattern and input parts
patternParts, patternPath := splitParts(pattern)
inputParts, inputPath := splitParts(inputCallbackURL)
// Verify everything except the path and query parameters
if len(patternParts) != len(inputParts) {
return false, nil
}
for i, patternPart := range patternParts {
matched, err := path.Match(patternPart, inputParts[i])
if err != nil || !matched {
return false, err
}
}
// Verify path with wildcard support
matched, err := matchPath(patternPath, inputPath)
if err != nil || !matched {
return false, err
}
// Verify query parameters
if len(patternQuery) != len(inputQuery) {
return false, nil
}
for patternKey, patternValues := range patternQuery {
inputValues, exists := inputQuery[patternKey]
if !exists {
return false, nil
}
if len(patternValues) != len(inputValues) {
return false, nil
}
for i := range patternValues {
matched, err := path.Match(patternValues[i], inputValues[i])
if err != nil || !matched {
return false, err
}
}
}
return true, nil
}
// matchPath matches the input path against the pattern with wildcard support
// Supported wildcards:
//
// '*' matches any sequence of characters except '/'
// '**' matches any sequence of characters including '/'
func matchPath(pattern string, input string) (matches bool, err error) {
var regexPattern strings.Builder
regexPattern.WriteString("^")
runes := []rune(pattern)
n := len(runes)
for i := 0; i < n; {
switch runes[i] {
case '*':
// Check if it's a ** (globstar)
if i+1 < n && runes[i+1] == '*' {
// globstar = .* (match slashes too)
regexPattern.WriteString(".*")
i += 2
} else {
// single * = [^/]* (no slash)
regexPattern.WriteString(`[^/]*`)
i++
}
default:
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
i++
}
}
regexPattern.WriteString("$")
matched, err := regexp.MatchString(regexPattern.String(), input)
return matched, err
}
// splitParts splits the URL into parts by special characters and returns the path separately
func splitParts(s string) (parts []string, path string) {
split := func(r rune) bool {
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
}
pathStart := -1
// Look for scheme:// first
if i := strings.Index(s, "://"); i >= 0 {
// Look for the next slash after scheme://
rest := s[i+3:]
if j := strings.IndexRune(rest, '/'); j >= 0 {
pathStart = i + 3 + j
}
} else {
// Otherwise, first slash is path start
pathStart = strings.IndexRune(s, '/')
}
if pathStart >= 0 {
path = s[pathStart:]
base := s[:pathStart]
parts = strings.FieldsFunc(base, split)
} else {
parts = strings.FieldsFunc(s, split)
path = ""
}
return parts, path
}

View File

@@ -1,784 +0,0 @@
package utils
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMatchCallbackURL(t *testing.T) {
tests := []struct {
name string
pattern string
input string
shouldMatch bool
}{
// Basic matching
{
"exact match",
"https://example.com/callback",
"https://example.com/callback",
true,
},
{
"no match",
"https://example.org/callback",
"https://example.com/callback",
false,
},
// Scheme
{
"scheme mismatch",
"https://example.com/callback",
"http://example.com/callback",
false,
},
{
"wildcard scheme",
"*://example.com/callback",
"https://example.com/callback",
true,
},
// Hostname
{
"hostname mismatch",
"https://example.com/callback",
"https://malicious.com/callback",
false,
},
{
"wildcard subdomain",
"https://*.example.com/callback",
"https://subdomain.example.com/callback",
true,
},
{
"partial wildcard in hostname prefix",
"https://app*.example.com/callback",
"https://app1.example.com/callback",
true,
},
{
"partial wildcard in hostname suffix",
"https://*-prod.example.com/callback",
"https://api-prod.example.com/callback",
true,
},
{
"partial wildcard in hostname middle",
"https://app-*-server.example.com/callback",
"https://app-staging-server.example.com/callback",
true,
},
{
"subdomain wildcard doesn't match domain hijack attempt",
"https://*.example.com/callback",
"https://malicious.site?url=abc.example.com/callback",
false,
},
{
"hostname mismatch with confusable characters",
"https://example.com/callback",
"https://examp1e.com/callback",
false,
},
{
"hostname mismatch with homograph attack",
"https://example.com/callback",
"https://еxample.com/callback",
false,
},
// Port
{
"port mismatch",
"https://example.com:8080/callback",
"https://example.com:9090/callback",
false,
},
{
"wildcard port",
"https://example.com:*/callback",
"https://example.com:8080/callback",
true,
},
{
"partial wildcard in port prefix",
"https://example.com:80*/callback",
"https://example.com:8080/callback",
true,
},
// Path
{
"path mismatch",
"https://example.com/callback",
"https://example.com/other",
false,
},
{
"wildcard path segment",
"https://example.com/api/*/callback",
"https://example.com/api/v1/callback",
true,
},
{
"wildcard entire path",
"https://example.com/*",
"https://example.com/callback",
true,
},
{
"partial wildcard in path prefix",
"https://example.com/test*",
"https://example.com/testcase",
true,
},
{
"partial wildcard in path suffix",
"https://example.com/*-callback",
"https://example.com/oauth-callback",
true,
},
{
"partial wildcard in path middle",
"https://example.com/api-*-v1/callback",
"https://example.com/api-internal-v1/callback",
true,
},
{
"multiple partial wildcards in path",
"https://example.com/*/test*/callback",
"https://example.com/v1/testing/callback",
true,
},
{
"multiple wildcard segments in path",
"https://example.com/**/callback",
"https://example.com/api/v1/foo/bar/callback",
true,
},
{
"multiple wildcard segments in path",
"https://example.com/**/v1/**/callback",
"https://example.com/api/v1/foo/bar/callback",
true,
},
{
"partial wildcard matching full path segment",
"https://example.com/foo-*",
"https://example.com/foo-bar",
true,
},
// Credentials
{
"username mismatch",
"https://user:pass@example.com/callback",
"https://admin:pass@example.com/callback",
false,
},
{
"missing credentials",
"https://user:pass@example.com/callback",
"https://example.com/callback",
false,
},
{
"unexpected credentials",
"https://example.com/callback",
"https://user:pass@example.com/callback",
false,
},
{
"wildcard password",
"https://user:*@example.com/callback",
"https://user:secret123@example.com/callback",
true,
},
{
"partial wildcard in username",
"https://admin*:pass@example.com/callback",
"https://admin123:pass@example.com/callback",
true,
},
{
"partial wildcard in password",
"https://user:pass*@example.com/callback",
"https://user:password123@example.com/callback",
true,
},
{
"wildcard password doesn't allow domain hijack",
"https://user:*@example.com/callback",
"https://user:password@malicious.site#example.com/callback",
false,
},
{
"credentials with @ in password trying to hijack hostname",
"https://user:pass@example.com/callback",
"https://user:pass@evil.com@example.com/callback",
false,
},
// Query parameters
{
"extra query parameter",
"https://example.com/callback?code=*",
"https://example.com/callback?code=abc123&extra=value",
false,
},
{
"missing query parameter",
"https://example.com/callback?code=*&state=*",
"https://example.com/callback?code=abc123",
false,
},
{
"query parameter after fragment",
"https://example.com/callback?code=123",
"https://example.com/callback#section?code=123",
false,
},
{
"query parameter name mismatch",
"https://example.com/callback?code=*",
"https://example.com/callback?token=abc123",
false,
},
{
"wildcard query parameter",
"https://example.com/callback?code=*",
"https://example.com/callback?code=abc123",
true,
},
{
"multiple query parameters",
"https://example.com/callback?code=*&state=*",
"https://example.com/callback?code=abc123&state=xyz789",
true,
},
{
"query parameters in different order",
"https://example.com/callback?state=*&code=*",
"https://example.com/callback?code=abc123&state=xyz789",
true,
},
{
"exact query parameter value",
"https://example.com/callback?mode=production",
"https://example.com/callback?mode=production",
true,
},
{
"query parameter value mismatch",
"https://example.com/callback?mode=production",
"https://example.com/callback?mode=development",
false,
},
{
"mixed exact and wildcard query parameters",
"https://example.com/callback?mode=production&code=*",
"https://example.com/callback?mode=production&code=abc123",
true,
},
{
"mixed exact and wildcard with wrong exact value",
"https://example.com/callback?mode=production&code=*",
"https://example.com/callback?mode=development&code=abc123",
false,
},
{
"multiple values for same parameter",
"https://example.com/callback?param=*&param=*",
"https://example.com/callback?param=value1&param=value2",
true,
},
{
"unexpected query parameters",
"https://example.com/callback",
"https://example.com/callback?extra=value",
false,
},
{
"query parameter with redirect to external site",
"https://example.com/callback?code=*",
"https://example.com/callback?code=123&redirect=https://evil.com",
false,
},
{
"open redirect via encoded URL in query param",
"https://example.com/callback?state=*",
"https://example.com/callback?state=abc&next=//evil.com",
false,
},
// Fragment
{
"fragment ignored when both pattern and input have fragment",
"https://example.com/callback#fragment",
"https://example.com/callback#fragment",
true,
},
{
"fragment ignored when pattern has fragment but input doesn't",
"https://example.com/callback#fragment",
"https://example.com/callback",
true,
},
{
"fragment ignored when input has fragment but pattern doesn't",
"https://example.com/callback",
"https://example.com/callback#section",
true,
},
// Path traversal and injection attempts
{
"path traversal attempt",
"https://example.com/callback",
"https://example.com/../admin/callback",
false,
},
{
"backslash instead of forward slash",
"https://example.com/callback",
"https://example.com\\callback",
false,
},
{
"double slash in hostname (protocol smuggling)",
"https://example.com/callback",
"https://example.com//evil.com/callback",
false,
},
{
"CRLF injection attempt in path",
"https://example.com/callback",
"https://example.com/callback%0d%0aLocation:%20https://evil.com",
false,
},
{
"null byte injection",
"https://example.com/callback",
"https://example.com/callback%00.evil.com",
false,
},
}
for _, tt := range tests {
matches, err := matchCallbackURL(tt.pattern, tt.input)
require.NoError(t, err, tt.name)
assert.Equal(t, tt.shouldMatch, matches, tt.name)
}
}
func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
tests := []struct {
name string
urls []string
inputCallbackURL string
expectedURL string
expectMatch bool
}{
{
name: "127.0.0.1 with dynamic port - exact match",
urls: []string{"http://127.0.0.1/callback"},
inputCallbackURL: "http://127.0.0.1:8080/callback",
expectedURL: "http://127.0.0.1:8080/callback",
expectMatch: true,
},
{
name: "127.0.0.1 with different port",
urls: []string{"http://127.0.0.1/callback"},
inputCallbackURL: "http://127.0.0.1:9999/callback",
expectedURL: "http://127.0.0.1:9999/callback",
expectMatch: true,
},
{
name: "IPv6 loopback with dynamic port",
urls: []string{"http://[::1]/callback"},
inputCallbackURL: "http://[::1]:8080/callback",
expectedURL: "http://[::1]:8080/callback",
expectMatch: true,
},
{
name: "IPv6 loopback without brackets in input",
urls: []string{"http://[::1]/callback"},
inputCallbackURL: "http://::1:8080/callback",
expectedURL: "http://::1:8080/callback",
expectMatch: true,
},
{
name: "localhost with dynamic port",
urls: []string{"http://localhost/callback"},
inputCallbackURL: "http://localhost:8080/callback",
expectedURL: "http://localhost:8080/callback",
expectMatch: true,
},
{
name: "https loopback doesn't trigger special handling",
urls: []string{"https://127.0.0.1/callback"},
inputCallbackURL: "https://127.0.0.1:8080/callback",
expectedURL: "",
expectMatch: false,
},
{
name: "loopback with path match",
urls: []string{"http://127.0.0.1/auth/*"},
inputCallbackURL: "http://127.0.0.1:3000/auth/callback",
expectedURL: "http://127.0.0.1:3000/auth/callback",
expectMatch: true,
},
{
name: "loopback with path mismatch",
urls: []string{"http://127.0.0.1/callback"},
inputCallbackURL: "http://127.0.0.1:8080/different",
expectedURL: "",
expectMatch: false,
},
{
name: "non-loopback IP",
urls: []string{"http://192.168.1.1/callback"},
inputCallbackURL: "http://192.168.1.1:8080/callback",
expectedURL: "",
expectMatch: false,
},
{
name: "wildcard matches loopback",
urls: []string{"*"},
inputCallbackURL: "http://127.0.0.1:8080/callback",
expectedURL: "http://127.0.0.1:8080/callback",
expectMatch: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL)
require.NoError(t, err)
if tt.expectMatch {
assert.Equal(t, tt.expectedURL, result)
} else {
assert.Empty(t, result)
}
})
}
}
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
tests := []struct {
name string
urls []string
inputCallbackURL string
expectedURL string
expectMatch bool
}{
{
name: "matches first pattern",
urls: []string{
"https://example.com/callback",
"https://example.org/callback",
},
inputCallbackURL: "https://example.com/callback",
expectedURL: "https://example.com/callback",
expectMatch: true,
},
{
name: "matches second pattern",
urls: []string{
"https://example.com/callback",
"https://example.org/callback",
},
inputCallbackURL: "https://example.org/callback",
expectedURL: "https://example.org/callback",
expectMatch: true,
},
{
name: "matches none",
urls: []string{
"https://example.com/callback",
"https://example.org/callback",
},
inputCallbackURL: "https://malicious.com/callback",
expectedURL: "",
expectMatch: false,
},
{
name: "matches wildcard pattern",
urls: []string{
"https://example.com/callback",
"https://*.example.org/callback",
},
inputCallbackURL: "https://subdomain.example.org/callback",
expectedURL: "https://subdomain.example.org/callback",
expectMatch: true,
},
{
name: "empty pattern list",
urls: []string{},
inputCallbackURL: "https://example.com/callback",
expectedURL: "",
expectMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := GetCallbackURLFromList(tt.urls, tt.inputCallbackURL)
require.NoError(t, err)
if tt.expectMatch {
assert.Equal(t, tt.expectedURL, result)
} else {
assert.Empty(t, result)
}
})
}
}
func TestMatchPath(t *testing.T) {
tests := []struct {
name string
pattern string
input string
shouldMatch bool
}{
// Exact matches
{
name: "exact match",
pattern: "/callback",
input: "/callback",
shouldMatch: true,
},
{
name: "exact mismatch",
pattern: "/callback",
input: "/other",
shouldMatch: false,
},
{
name: "empty paths",
pattern: "",
input: "",
shouldMatch: true,
},
// Single wildcard (*)
{
name: "single wildcard matches segment",
pattern: "/api/*/callback",
input: "/api/v1/callback",
shouldMatch: true,
},
{
name: "single wildcard doesn't match multiple segments",
pattern: "/api/*/callback",
input: "/api/v1/v2/callback",
shouldMatch: false,
},
{
name: "single wildcard at end",
pattern: "/callback/*",
input: "/callback/test",
shouldMatch: true,
},
{
name: "single wildcard at start",
pattern: "/*/callback",
input: "/api/callback",
shouldMatch: true,
},
{
name: "multiple single wildcards",
pattern: "/*/test/*",
input: "/api/test/callback",
shouldMatch: true,
},
{
name: "partial wildcard prefix",
pattern: "/test*",
input: "/testing",
shouldMatch: true,
},
{
name: "partial wildcard suffix",
pattern: "/*-callback",
input: "/oauth-callback",
shouldMatch: true,
},
{
name: "partial wildcard middle",
pattern: "/api-*-v1",
input: "/api-internal-v1",
shouldMatch: true,
},
// Double wildcard (**)
{
name: "double wildcard matches multiple segments",
pattern: "/api/**/callback",
input: "/api/v1/v2/v3/callback",
shouldMatch: true,
},
{
name: "double wildcard matches single segment",
pattern: "/api/**/callback",
input: "/api/v1/callback",
shouldMatch: true,
},
{
name: "double wildcard doesn't match when pattern has extra slashes",
pattern: "/api/**/callback",
input: "/api/callback",
shouldMatch: false,
},
{
name: "double wildcard at end",
pattern: "/api/**",
input: "/api/v1/v2/callback",
shouldMatch: true,
},
{
name: "double wildcard in middle",
pattern: "/api/**/v2/**/callback",
input: "/api/v1/v2/v3/v4/callback",
shouldMatch: true,
},
// Complex patterns
{
name: "mix of single and double wildcards",
pattern: "/*/api/**/callback",
input: "/app/api/v1/v2/callback",
shouldMatch: true,
},
{
name: "wildcard with special characters",
pattern: "/callback-*",
input: "/callback-123",
shouldMatch: true,
},
{
name: "path with query-like string (no special handling)",
pattern: "/callback?code=*",
input: "/callback?code=abc",
shouldMatch: true,
},
// Edge cases
{
name: "single wildcard matches empty segment",
pattern: "/api/*/callback",
input: "/api//callback",
shouldMatch: true,
},
{
name: "pattern longer than input",
pattern: "/api/v1/callback",
input: "/api",
shouldMatch: false,
},
{
name: "input longer than pattern",
pattern: "/api",
input: "/api/v1/callback",
shouldMatch: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
matches, err := matchPath(tt.pattern, tt.input)
require.NoError(t, err)
assert.Equal(t, tt.shouldMatch, matches)
})
}
}
func TestSplitParts(t *testing.T) {
tests := []struct {
name string
input string
expectedParts []string
expectedPath string
}{
{
name: "simple https URL",
input: "https://example.com/callback",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL with port",
input: "https://example.com:8080/callback",
expectedParts: []string{"https", "example", "com", "8080"},
expectedPath: "/callback",
},
{
name: "URL with subdomain",
input: "https://api.example.com/callback",
expectedParts: []string{"https", "api", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL with credentials",
input: "https://user:pass@example.com/callback",
expectedParts: []string{"https", "user", "pass", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL without path",
input: "https://example.com",
expectedParts: []string{"https", "example", "com"},
expectedPath: "",
},
{
name: "URL with deep path",
input: "https://example.com/api/v1/callback",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/api/v1/callback",
},
{
name: "URL with path and query",
input: "https://example.com/callback?code=123",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/callback?code=123",
},
{
name: "URL with trailing slash",
input: "https://example.com/",
expectedParts: []string{"https", "example", "com"},
expectedPath: "/",
},
{
name: "URL with multiple subdomains",
input: "https://api.v1.staging.example.com/callback",
expectedParts: []string{"https", "api", "v1", "staging", "example", "com"},
expectedPath: "/callback",
},
{
name: "URL with port and credentials",
input: "https://user:pass@example.com:8080/callback",
expectedParts: []string{"https", "user", "pass", "example", "com", "8080"},
expectedPath: "/callback",
},
{
name: "scheme with authority separator but no slash",
input: "http://example.com",
expectedParts: []string{"http", "example", "com"},
expectedPath: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
parts, path := splitParts(tt.input)
assert.Equal(t, tt.expectedParts, parts, "parts mismatch")
assert.Equal(t, tt.expectedPath, path, "path mismatch")
})
}
}

View File

@@ -1,8 +1,6 @@
package cookie
import (
"time"
"github.com/gin-gonic/gin"
)
@@ -13,7 +11,3 @@ func AddAccessTokenCookie(c *gin.Context, maxAgeInSeconds int, token string) {
func AddSessionIdCookie(c *gin.Context, maxAgeInSeconds int, sessionID string) {
c.SetCookie(SessionIdCookieName, sessionID, maxAgeInSeconds, "/", "", true, true)
}
func AddDeviceTokenCookie(c *gin.Context, deviceToken string) {
c.SetCookie(DeviceTokenCookieName, deviceToken, int(15*time.Minute.Seconds()), "/api/one-time-access-token", "", true, true)
}

View File

@@ -8,12 +8,10 @@ import (
var AccessTokenCookieName = "__Host-access_token"
var SessionIdCookieName = "__Host-session"
var DeviceTokenCookieName = "__Host-device_token" //nolint:gosec
func init() {
if strings.HasPrefix(common.EnvConfig.AppURL, "http://") {
AccessTokenCookieName = "access_token"
SessionIdCookieName = "session"
DeviceTokenCookieName = "device_token"
}
}

View File

@@ -1,130 +0,0 @@
package utils
import (
"database/sql"
"errors"
"fmt"
"log/slog"
"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/iofs"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/resources"
)
// MigrateDatabase applies database migrations using embedded migration files or fetches them from GitHub if a downgrade is detected.
func MigrateDatabase(sqlDb *sql.DB) error {
m, err := GetEmbeddedMigrateInstance(sqlDb)
if err != nil {
return fmt.Errorf("failed to get migrate instance: %w", err)
}
path := "migrations/" + string(common.EnvConfig.DbProvider)
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(sqlDb, 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
}
// GetEmbeddedMigrateInstance creates a migrate.Migrate instance using embedded migration files.
func GetEmbeddedMigrateInstance(sqlDb *sql.DB) (*migrate.Migrate, error) {
path := "migrations/" + string(common.EnvConfig.DbProvider)
source, err := iofs.New(resources.FS, path)
if err != nil {
return nil, fmt.Errorf("failed to create embedded migration source: %w", err)
}
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
if err != nil {
return nil, fmt.Errorf("failed to create migration driver: %w", err)
}
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
if err != nil {
return nil, fmt.Errorf("failed to create migration instance: %w", err)
}
return m, nil
}
// newMigrationDriver creates a database.Driver instance based on the given database provider.
func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver database.Driver, err error) {
switch dbProvider {
case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
NoTxWrap: true,
})
case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default:
// Should never happen at this point
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
}
if err != nil {
return nil, fmt.Errorf("failed to create migration driver: %w", err)
}
return driver, nil
}
// migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades.
func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
if err != nil {
return fmt.Errorf("failed to create migration driver: %w", err)
}
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
}

View File

@@ -1,116 +0,0 @@
package utils
import (
"fmt"
"strings"
"gorm.io/gorm"
)
// DBTableExists checks if a table exists in the database
func DBTableExists(db *gorm.DB, tableName string) (exists bool, err error) {
switch db.Name() {
case "postgres":
query := `SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = ?
)`
err = db.Raw(query, tableName).Scan(&exists).Error
if err != nil {
return false, err
}
case "sqlite":
query := `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?`
err = db.Raw(query, tableName).Scan(&exists).Error
if err != nil {
return false, err
}
default:
return false, fmt.Errorf("unsupported database dialect: %s", db.Name())
}
return exists, nil
}
type DBSchemaColumn struct {
Name string
Nullable bool
}
type DBSchemaTableTypes = map[string]DBSchemaColumn
type DBSchemaTypes = map[string]DBSchemaTableTypes
// LoadDBSchemaTypes retrieves the column types for all tables in the DB
// Result is a map of "table --> column --> {name: column type name, nullable: boolean}"
func LoadDBSchemaTypes(db *gorm.DB) (result DBSchemaTypes, err error) {
result = make(DBSchemaTypes)
switch db.Name() {
case "postgres":
var rows []struct {
TableName string
ColumnName string
DataType string
Nullable bool
}
err := db.
Raw(`
SELECT table_name, column_name, data_type, is_nullable = 'YES' AS nullable
FROM information_schema.columns
WHERE table_schema = 'public';
`).
Scan(&rows).
Error
if err != nil {
return nil, err
}
for _, r := range rows {
t := strings.ToLower(r.DataType)
if result[r.TableName] == nil {
result[r.TableName] = make(map[string]DBSchemaColumn)
}
result[r.TableName][r.ColumnName] = DBSchemaColumn{
Name: strings.ToLower(t),
Nullable: r.Nullable,
}
}
case "sqlite":
var tables []string
err = db.
Raw(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';`).
Scan(&tables).
Error
if err != nil {
return nil, err
}
for _, table := range tables {
var cols []struct {
Name string
Type string
Notnull bool
}
err := db.
Raw(`PRAGMA table_info("` + table + `");`).
Scan(&cols).
Error
if err != nil {
return nil, err
}
for _, c := range cols {
if result[table] == nil {
result[table] = make(map[string]DBSchemaColumn)
}
result[table][c.Name] = DBSchemaColumn{
Name: strings.ToLower(c.Type),
Nullable: !c.Notnull,
}
}
}
default:
return nil, fmt.Errorf("unsupported database dialect: %s", db.Name())
}
return result, nil
}

View File

@@ -12,36 +12,24 @@ import (
"golang.org/x/image/font"
"golang.org/x/image/font/opentype"
"golang.org/x/image/math/fixed"
"golang.org/x/image/webp"
"github.com/pocket-id/pocket-id/backend/resources"
)
const profilePictureSize = 300
// CreateProfilePicture resizes the profile picture to a square and encodes it as PNG
func CreateProfilePicture(file io.ReadSeeker) (io.ReadSeeker, error) {
// Attempt standard formats first
// CreateProfilePicture resizes the profile picture to a square
func CreateProfilePicture(file io.Reader) (io.ReadSeeker, error) {
img, _, err := imageorient.Decode(file)
if err != nil {
if _, seekErr := file.Seek(0, io.SeekStart); seekErr != nil {
return nil, fmt.Errorf("failed to seek file: %w", seekErr)
}
// Try WebP
webpImg, webpErr := webp.Decode(file)
if webpErr != nil {
return nil, fmt.Errorf("failed to decode image: %w", err)
}
img = webpImg
return nil, fmt.Errorf("failed to decode image: %w", err)
}
// Resize to square
img = imaging.Fill(img, profilePictureSize, profilePictureSize, imaging.Center, imaging.Lanczos)
// Encode back to PNG
var buf bytes.Buffer
if err := imaging.Encode(&buf, img, imaging.PNG); err != nil {
err = imaging.Encode(&buf, img, imaging.PNG)
if err != nil {
return nil, fmt.Errorf("failed to encode image: %w", err)
}

View File

@@ -1,10 +1,7 @@
package utils
import (
"context"
"errors"
"net"
"net/url"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/common"
@@ -59,23 +56,6 @@ func IsPrivateIP(ip net.IP) bool {
return IsLocalhostIP(ip) || IsPrivateLanIP(ip) || IsTailscaleIP(ip) || IsLocalIPv6(ip)
}
func IsURLPrivate(ctx context.Context, u *url.URL) (bool, error) {
var r net.Resolver
ips, err := r.LookupIPAddr(ctx, u.Hostname())
if err != nil || len(ips) == 0 {
return false, errors.New("cannot resolve hostname")
}
// Prevents SSRF by allowing only public IPs
for _, addr := range ips {
if IsPrivateIP(addr.IP) {
return true, nil
}
}
return false, nil
}
func listContainsIP(ipNets []*net.IPNet, ip net.IP) bool {
for _, ipNet := range ipNets {
if ipNet.Contains(ip) {

View File

@@ -1,14 +1,8 @@
package utils
import (
"context"
"net"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/common"
)
@@ -26,8 +20,9 @@ func TestIsLocalhostIP(t *testing.T) {
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := IsLocalhostIP(ip)
assert.Equal(t, tt.expected, got)
if got := IsLocalhostIP(ip); got != tt.expected {
t.Errorf("IsLocalhostIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
@@ -45,8 +40,9 @@ func TestIsPrivateLanIP(t *testing.T) {
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := IsPrivateLanIP(ip)
assert.Equal(t, tt.expected, got)
if got := IsPrivateLanIP(ip); got != tt.expected {
t.Errorf("IsPrivateLanIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
@@ -63,9 +59,9 @@ func TestIsTailscaleIP(t *testing.T) {
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := IsTailscaleIP(ip)
assert.Equal(t, tt.expected, got)
if got := IsTailscaleIP(ip); got != tt.expected {
t.Errorf("IsTailscaleIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
@@ -90,17 +86,16 @@ func TestIsLocalIPv6(t *testing.T) {
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := IsLocalIPv6(ip)
assert.Equal(t, tt.expected, got)
if got := IsLocalIPv6(ip); got != tt.expected {
t.Errorf("IsLocalIPv6(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestIsPrivateIP(t *testing.T) {
// Save and restore env config
origRanges := common.EnvConfig.LocalIPv6Ranges
t.Cleanup(func() {
common.EnvConfig.LocalIPv6Ranges = origRanges
})
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
common.EnvConfig.LocalIPv6Ranges = "fd00::/8"
localIPv6Ranges = nil // reset
@@ -120,8 +115,9 @@ func TestIsPrivateIP(t *testing.T) {
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := IsPrivateIP(ip)
assert.Equal(t, tt.expected, got)
if got := IsPrivateIP(ip); got != tt.expected {
t.Errorf("IsPrivateIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
@@ -142,202 +138,22 @@ func TestListContainsIP(t *testing.T) {
for _, tt := range tests {
ip := net.ParseIP(tt.ip)
got := listContainsIP(list, ip)
assert.Equal(t, tt.expected, got)
if got := listContainsIP(list, ip); got != tt.expected {
t.Errorf("listContainsIP(%s) = %v, want %v", tt.ip, got, tt.expected)
}
}
}
func TestInit_LocalIPv6Ranges(t *testing.T) {
// Save and restore env config
origRanges := common.EnvConfig.LocalIPv6Ranges
t.Cleanup(func() {
common.EnvConfig.LocalIPv6Ranges = origRanges
})
defer func() { common.EnvConfig.LocalIPv6Ranges = origRanges }()
common.EnvConfig.LocalIPv6Ranges = "fd00::/8, invalidCIDR ,fc00::/7"
localIPv6Ranges = nil
loadLocalIPv6Ranges()
assert.Len(t, localIPv6Ranges, 2)
}
func TestIsURLPrivate(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
tests := []struct {
name string
urlStr string
expectPriv bool
expectError bool
}{
{
name: "localhost by name",
urlStr: "http://localhost",
expectPriv: true,
expectError: false,
},
{
name: "localhost with port",
urlStr: "http://localhost:8080",
expectPriv: true,
expectError: false,
},
{
name: "127.0.0.1 IP",
urlStr: "http://127.0.0.1",
expectPriv: true,
expectError: false,
},
{
name: "127.0.0.1 with port",
urlStr: "http://127.0.0.1:3000",
expectPriv: true,
expectError: false,
},
{
name: "IPv6 loopback",
urlStr: "http://[::1]",
expectPriv: true,
expectError: false,
},
{
name: "IPv6 loopback with port",
urlStr: "http://[::1]:8080",
expectPriv: true,
expectError: false,
},
{
name: "private IP 10.x.x.x",
urlStr: "http://10.0.0.1",
expectPriv: true,
expectError: false,
},
{
name: "private IP 192.168.x.x",
urlStr: "http://192.168.1.1",
expectPriv: true,
expectError: false,
},
{
name: "private IP 172.16.x.x",
urlStr: "http://172.16.0.1",
expectPriv: true,
expectError: false,
},
{
name: "Tailscale IP",
urlStr: "http://100.64.0.1",
expectPriv: true,
expectError: false,
},
{
name: "public IP - Google DNS",
urlStr: "http://8.8.8.8",
expectPriv: false,
expectError: false,
},
{
name: "public IP - Cloudflare DNS",
urlStr: "http://1.1.1.1",
expectPriv: false,
expectError: false,
},
{
name: "invalid hostname",
urlStr: "http://this-should-not-resolve-ever-123456789.invalid",
expectPriv: false,
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.urlStr)
require.NoError(t, err, "Failed to parse URL %s", tt.urlStr)
isPriv, err := IsURLPrivate(ctx, u)
if tt.expectError {
require.Error(t, err, "IsURLPrivate(%s) expected error but got none", tt.urlStr)
} else {
require.NoError(t, err, "IsURLPrivate(%s) unexpected error", tt.urlStr)
assert.Equal(t, tt.expectPriv, isPriv, "IsURLPrivate(%s)", tt.urlStr)
}
})
if len(localIPv6Ranges) != 2 {
t.Errorf("expected 2 valid IPv6 ranges, got %d", len(localIPv6Ranges))
}
}
func TestIsURLPrivate_WithDomainName(t *testing.T) {
// Note: These tests rely on actual DNS resolution
// They test real public domains to ensure they are not flagged as private
ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second)
defer cancel()
tests := []struct {
name string
urlStr string
expectPriv bool
}{
{
name: "Google public domain",
urlStr: "https://www.google.com",
expectPriv: false,
},
{
name: "GitHub public domain",
urlStr: "https://github.com",
expectPriv: false,
},
{
// localhost.localtest.me is a well-known domain that resolves to 127.0.0.1
name: "localhost.localtest.me resolves to 127.0.0.1",
urlStr: "http://localhost.localtest.me",
expectPriv: true,
},
{
// 10.0.0.1.nip.io resolves to 10.0.0.1 (private IP)
name: "nip.io domain resolving to private 10.x IP",
urlStr: "http://10.0.0.1.nip.io",
expectPriv: true,
},
{
// 192.168.1.1.nip.io resolves to 192.168.1.1 (private IP)
name: "nip.io domain resolving to private 192.168.x IP",
urlStr: "http://192.168.1.1.nip.io",
expectPriv: true,
},
{
// 127.0.0.1.nip.io resolves to 127.0.0.1 (localhost)
name: "nip.io domain resolving to localhost",
urlStr: "http://127.0.0.1.nip.io",
expectPriv: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.urlStr)
require.NoError(t, err, "Failed to parse URL %s", tt.urlStr)
isPriv, err := IsURLPrivate(ctx, u)
if err != nil {
t.Skipf("DNS resolution failed for %s (network issue?): %v", tt.urlStr, err)
return
}
assert.Equal(t, tt.expectPriv, isPriv, "IsURLPrivate(%s)", tt.urlStr)
})
}
}
func TestIsURLPrivate_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel() // Cancel immediately
u, err := url.Parse("http://example.com")
require.NoError(t, err, "Failed to parse URL")
_, err = IsURLPrivate(ctx, u)
assert.Error(t, err, "IsURLPrivate with cancelled context expected error but got none")
}

View File

@@ -28,14 +28,22 @@ func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID s
return nil, fmt.Errorf("failed to load encryption key: %w", err)
}
keyProvider = &KeyProviderDatabase{}
// Get the key provider
switch envConfig.KeysStorage {
case "file", "":
keyProvider = &KeyProviderFile{}
case "database":
keyProvider = &KeyProviderDatabase{}
default:
return nil, fmt.Errorf("invalid key storage '%s'", envConfig.KeysStorage)
}
err = keyProvider.Init(KeyProviderOpts{
DB: db,
EnvConfig: envConfig,
Kek: kek,
})
if err != nil {
return nil, fmt.Errorf("failed to init key provider: %w", err)
return nil, fmt.Errorf("failed to init key provider of type '%s': %w", envConfig.KeysStorage, err)
}
return keyProvider, nil

View File

@@ -0,0 +1,202 @@
package jwk
import (
"encoding/base64"
"fmt"
"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/utils"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
const (
// PrivateKeyFile is the path in the data/keys folder where the key is stored
// This is a JSON file containing a key encoded as JWK
PrivateKeyFile = "jwt_private_key.json"
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
// This is a encrypted JSON file containing a key encoded as JWK
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
)
type KeyProviderFile struct {
envConfig *common.EnvConfigSchema
kek []byte
}
func (f *KeyProviderFile) Init(opts KeyProviderOpts) error {
f.envConfig = opts.EnvConfig
f.kek = opts.Kek
return nil
}
func (f *KeyProviderFile) LoadKey() (jwk.Key, error) {
if len(f.kek) > 0 {
return f.loadEncryptedKey()
}
return f.loadKey()
}
func (f *KeyProviderFile) SaveKey(key jwk.Key) error {
if len(f.kek) > 0 {
return f.saveKeyEncrypted(key)
}
return f.saveKey(key)
}
func (f *KeyProviderFile) loadKey() (jwk.Key, error) {
var key jwk.Key
// First, check if we have a JWK file
// If we do, then we just load that
jwkPath := f.jwkPath()
ok, err := utils.FileExists(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to check if private key file exists at path '%s': %w", jwkPath, err)
}
if !ok {
// File doesn't exist, no key was loaded
return nil, nil
}
data, err := os.ReadFile(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to read private key file at path '%s': %w", jwkPath, err)
}
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse private key file at path '%s': %w", jwkPath, err)
}
return key, nil
}
func (f *KeyProviderFile) loadEncryptedKey() (key jwk.Key, err error) {
// First, check if we have an encrypted JWK file
// If we do, then we just load that
encJwkPath := f.encJwkPath()
ok, err := utils.FileExists(encJwkPath)
if err != nil {
return nil, fmt.Errorf("failed to check if encrypted private key file exists at path '%s': %w", encJwkPath, err)
}
if ok {
encB64, err := os.ReadFile(encJwkPath)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': %w", encJwkPath, err)
}
// Decode from base64
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
n, err := base64.StdEncoding.Decode(enc, encB64)
if err != nil {
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': not a valid base64-encoded file: %w", encJwkPath, err)
}
// Decrypt the data
data, err := cryptoutils.Decrypt(f.kek, enc[:n], nil)
if err != nil {
return nil, fmt.Errorf("failed to decrypt private key file at path '%s': %w", encJwkPath, err)
}
// Parse the key
key, err = jwk.ParseKey(data)
if err != nil {
return nil, fmt.Errorf("failed to parse encrypted private key file at path '%s': %w", encJwkPath, err)
}
return key, nil
}
// Check if we have an un-encrypted JWK file
key, err = f.loadKey()
if err != nil {
return nil, fmt.Errorf("failed to load un-encrypted key file: %w", err)
}
if key == nil {
// No key exists, encrypted or un-encrypted
return nil, nil
}
// If we are here, we have loaded a key that was un-encrypted
// We need to replace the plaintext key with the encrypted one before we return
err = f.saveKeyEncrypted(key)
if err != nil {
return nil, fmt.Errorf("failed to save encrypted key file: %w", err)
}
jwkPath := f.jwkPath()
err = os.Remove(jwkPath)
if err != nil {
return nil, fmt.Errorf("failed to remove un-encrypted key file at path '%s': %w", jwkPath, err)
}
return key, nil
}
func (f *KeyProviderFile) saveKey(key jwk.Key) error {
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for key file: %w", f.envConfig.KeysPath, err)
}
jwkPath := f.jwkPath()
keyFile, err := os.OpenFile(jwkPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file at path '%s': %w", jwkPath, err)
}
defer keyFile.Close()
// Write the JSON file to disk
err = EncodeJWK(keyFile, key)
if err != nil {
return fmt.Errorf("failed to write key file at path '%s': %w", jwkPath, err)
}
return nil
}
func (f *KeyProviderFile) saveKeyEncrypted(key jwk.Key) error {
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
if err != nil {
return fmt.Errorf("failed to create directory '%s' for encrypted key file: %w", f.envConfig.KeysPath, err)
}
// Encode the key to JSON
data, err := EncodeJWKBytes(key)
if err != nil {
return fmt.Errorf("failed to encode key to JSON: %w", err)
}
// Encrypt the key then encode to Base64
enc, err := cryptoutils.Encrypt(f.kek, data, nil)
if err != nil {
return fmt.Errorf("failed to encrypt key: %w", err)
}
encB64 := make([]byte, base64.StdEncoding.EncodedLen(len(enc)))
base64.StdEncoding.Encode(encB64, enc)
// Write to disk
encJwkPath := f.encJwkPath()
err = os.WriteFile(encJwkPath, encB64, 0600)
if err != nil {
return fmt.Errorf("failed to write encrypted key file at path '%s': %w", encJwkPath, err)
}
return nil
}
func (f *KeyProviderFile) jwkPath() string {
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFile)
}
func (f *KeyProviderFile) encJwkPath() string {
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFileEncrypted)
}
// Compile-time interface check
var _ KeyProvider = (*KeyProviderFile)(nil)

View File

@@ -0,0 +1,320 @@
package jwk
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"testing"
"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/common"
"github.com/pocket-id/pocket-id/backend/internal/utils"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
)
func TestKeyProviderFile_LoadKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("LoadKey with no existing key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
})
t.Run("LoadKey with no existing key (with kek)", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: makeKEK(t),
})
require.NoError(t, err)
// Load key when none exists
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
})
t.Run("LoadKey with unencrypted key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save a key
err = provider.SaveKey(key)
require.NoError(t, err)
// Make sure the key file exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected key file to exist")
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey with encrypted key", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: makeKEK(t),
})
require.NoError(t, err)
// Save a key (will be encrypted)
err = provider.SaveKey(key)
require.NoError(t, err)
// Make sure the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err := utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist")
// Make sure the unencrypted key file does not exist
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to not exist")
// Load the key
loadedKey, err := provider.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when encrypted key exists")
// Verify the loaded key is the same as the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
})
t.Run("LoadKey replaces unencrypted key with encrypted key when kek is provided", func(t *testing.T) {
tempDir := t.TempDir()
// First, create an unencrypted key
providerNoKek := &KeyProviderFile{}
err := providerNoKek.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save an unencrypted key
err = providerNoKek.SaveKey(key)
require.NoError(t, err)
// Verify unencrypted key exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected unencrypted key file to exist")
// Now create a provider with a kek
kek := make([]byte, 32)
_, err = rand.Read(kek)
require.NoError(t, err)
providerWithKek := &KeyProviderFile{}
err = providerWithKek.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: kek,
})
require.NoError(t, err)
// Load the key - this should convert the unencrypted key to encrypted
loadedKey, err := providerWithKek.LoadKey()
require.NoError(t, err)
assert.NotNil(t, loadedKey, "Expected non-nil key when loading and converting key")
// Verify the unencrypted key no longer exists
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to be removed")
// Verify the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err = utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist after conversion")
// Verify the key data
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key after conversion")
})
}
func TestKeyProviderFile_SaveKey(t *testing.T) {
// Generate a test key to use in our tests
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
require.NoError(t, err)
key, err := jwk.Import(pk)
require.NoError(t, err)
t.Run("SaveKey unencrypted", func(t *testing.T) {
tempDir := t.TempDir()
provider := &KeyProviderFile{}
err := provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
})
require.NoError(t, err)
// Save the key
err = provider.SaveKey(key)
require.NoError(t, err)
// Verify the key file exists
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err := utils.FileExists(keyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected key file to exist")
// Verify the content of the key file
data, err := os.ReadFile(keyPath)
require.NoError(t, err)
parsedKey, err := jwk.ParseKey(data)
require.NoError(t, err)
// Compare the saved key with the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected saved key to match original key")
})
t.Run("SaveKey encrypted", func(t *testing.T) {
tempDir := t.TempDir()
// Generate a 64-byte kek
kek := makeKEK(t)
provider := &KeyProviderFile{}
err = provider.Init(KeyProviderOpts{
EnvConfig: &common.EnvConfigSchema{
KeysPath: tempDir,
},
Kek: kek,
})
require.NoError(t, err)
// Save the key (will be encrypted)
err = provider.SaveKey(key)
require.NoError(t, err)
// Verify the encrypted key file exists
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
exists, err := utils.FileExists(encKeyPath)
require.NoError(t, err)
assert.True(t, exists, "Expected encrypted key file to exist")
// Verify the unencrypted key file doesn't exist
keyPath := filepath.Join(tempDir, PrivateKeyFile)
exists, err = utils.FileExists(keyPath)
require.NoError(t, err)
assert.False(t, exists, "Expected unencrypted key file to not exist")
// Manually decrypt the encrypted key file to verify it contains the correct key
encB64, err := os.ReadFile(encKeyPath)
require.NoError(t, err)
// Decode from base64
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
n, err := base64.StdEncoding.Decode(enc, encB64)
require.NoError(t, err)
enc = enc[:n] // Trim any padding
// Decrypt the data
data, err := cryptoutils.Decrypt(kek, enc, nil)
require.NoError(t, err)
// Parse the key
parsedKey, err := jwk.ParseKey(data)
require.NoError(t, err)
// Compare the decrypted key with the original
keyBytes, err := EncodeJWKBytes(key)
require.NoError(t, err)
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
require.NoError(t, err)
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected decrypted key to match original key")
})
}
func makeKEK(t *testing.T) []byte {
t.Helper()
// Generate a 32-byte kek
kek := make([]byte, 32)
_, err := rand.Read(kek)
require.NoError(t, err)
return kek
}

View File

@@ -38,7 +38,6 @@ func (r *ServiceRunner) Run(ctx context.Context) error {
// Ignore context canceled errors here as they generally indicate that the service is stopping
if rErr != nil && !errors.Is(rErr, context.Canceled) {
cancel()
errCh <- rErr
return
}

View File

@@ -61,26 +61,6 @@ func TestServiceRunner_Run(t *testing.T) {
require.ErrorIs(t, err, expectedErr)
})
t.Run("service error cancels others", func(t *testing.T) {
expectedErr := errors.New("boom")
errorService := func(ctx context.Context) error {
return expectedErr
}
waitingService := func(ctx context.Context) error {
<-ctx.Done()
return ctx.Err()
}
runner := NewServiceRunner(errorService, waitingService)
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
err := runner.Run(ctx)
require.Error(t, err)
require.ErrorIs(t, err, expectedErr)
})
t.Run("context canceled", func(t *testing.T) {
// Create a service that waits until context is canceled
waitingService := func(ctx context.Context) error {

View File

@@ -1,21 +0,0 @@
package utils
import (
"context"
"time"
)
func SleepWithContext(ctx context.Context, delay time.Duration) error {
if delay <= 0 {
return nil
}
timer := time.NewTimer(delay)
defer timer.Stop()
select {
case <-ctx.Done():
return ctx.Err()
case <-timer.C:
return nil
}
}

View File

@@ -1,34 +0,0 @@
package utils
import (
"errors"
"io"
)
var ErrSizeExceeded = errors.New("stream size exceeded")
// LimitReader is like io.LimitReader but throws an error if the stream exceeds the max size
// io.LimitReader instead just returns io.EOF
// Adapted from https://github.com/golang/go/issues/51115#issuecomment-1079761212
type LimitReader struct {
io.ReadCloser
N int64
}
func NewLimitReader(r io.ReadCloser, limit int64) *LimitReader {
return &LimitReader{r, limit}
}
func (r *LimitReader) Read(p []byte) (n int, err error) {
if r.N <= 0 {
return 0, ErrSizeExceeded
}
if int64(len(p)) > r.N {
p = p[0:r.N]
}
n, err = r.ReadCloser.Read(p)
r.N -= int64(n)
return
}

View File

@@ -14,17 +14,6 @@ import (
// GenerateRandomAlphanumericString generates a random alphanumeric string of the given length
func GenerateRandomAlphanumericString(length int) (string, error) {
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
return GenerateRandomString(length, charset)
}
// GenerateRandomUnambiguousString generates a random string of the given length using unambiguous characters
func GenerateRandomUnambiguousString(length int) (string, error) {
const charset = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789"
return GenerateRandomString(length, charset)
}
// GenerateRandomString generates a random string of the given length using the provided character set
func GenerateRandomString(length int, charset string) (string, error) {
if length <= 0 {
return "", errors.New("length must be a positive integer")

View File

@@ -2,7 +2,6 @@ package utils
import (
"regexp"
"strings"
"testing"
)
@@ -50,77 +49,6 @@ func TestGenerateRandomAlphanumericString(t *testing.T) {
})
}
func TestGenerateRandomUnambiguousString(t *testing.T) {
t.Run("valid length returns correct string", func(t *testing.T) {
const length = 10
str, err := GenerateRandomUnambiguousString(length)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if len(str) != length {
t.Errorf("Expected length %d, got %d", length, len(str))
}
matched, err := regexp.MatchString(`^[abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789]+$`, str)
if err != nil {
t.Errorf("Regex match failed: %v", err)
}
if !matched {
t.Errorf("String contains ambiguous characters: %s", str)
}
})
t.Run("zero length returns error", func(t *testing.T) {
_, err := GenerateRandomUnambiguousString(0)
if err == nil {
t.Error("Expected error for zero length, got nil")
}
})
t.Run("negative length returns error", func(t *testing.T) {
_, err := GenerateRandomUnambiguousString(-1)
if err == nil {
t.Error("Expected error for negative length, got nil")
}
})
}
func TestGenerateRandomString(t *testing.T) {
t.Run("valid length returns characters from charset", func(t *testing.T) {
const length = 20
const charset = "abc"
str, err := GenerateRandomString(length, charset)
if err != nil {
t.Errorf("Expected no error, got %v", err)
}
if len(str) != length {
t.Errorf("Expected length %d, got %d", length, len(str))
}
for _, r := range str {
if !strings.ContainsRune(charset, r) {
t.Fatalf("String contains character outside charset: %q", r)
}
}
})
t.Run("zero length returns error", func(t *testing.T) {
_, err := GenerateRandomString(0, "abc")
if err == nil {
t.Error("Expected error for zero length, got nil")
}
})
t.Run("negative length returns error", func(t *testing.T) {
_, err := GenerateRandomString(-1, "abc")
if err == nil {
t.Error("Expected error for negative length, got nil")
}
})
}
func TestCapitalizeFirstLetter(t *testing.T) {
tests := []struct {
name string

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
../../../tests/database.json

View File

@@ -1,72 +0,0 @@
package resources
import (
"embed"
"slices"
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// This test is meant to enforce that for every new migration added, a file with the same migration number exists for all supported databases
// This is necessary to ensure import/export works correctly
// Note: if a migration is not needed for a database, ensure there's a file with an empty (no-op) migration (e.g. even just a comment)
func TestMigrationsMatchingVersions(t *testing.T) {
// We can ignore migrations with version below 20251115000000
const ignoreBefore = 20251115000000
// Scan postgres migrations
postgresMigrations := scanMigrations(t, FS, "migrations/postgres", ignoreBefore)
// Scan sqlite migrations
sqliteMigrations := scanMigrations(t, FS, "migrations/sqlite", ignoreBefore)
// Sort both lists for consistent comparison
slices.Sort(postgresMigrations)
slices.Sort(sqliteMigrations)
// Compare the lists
assert.Equal(t, postgresMigrations, sqliteMigrations, "Migration versions must match between Postgres and SQLite")
}
// scanMigrations scans a directory for migration files and returns a list of versions
func scanMigrations(t *testing.T, fs embed.FS, dir string, ignoreBefore int64) []int64 {
t.Helper()
entries, err := fs.ReadDir(dir)
require.NoErrorf(t, err, "Failed to read directory '%s'", dir)
// Divide by 2 because of up and down files
versions := make([]int64, 0, len(entries)/2)
for _, entry := range entries {
if entry.IsDir() {
continue
}
filename := entry.Name()
// Only consider .up.sql files
if !strings.HasSuffix(filename, ".up.sql") {
continue
}
// Extract version from filename (format: <version>_<anything>.up.sql)
versionString, _, ok := strings.Cut(filename, "_")
require.Truef(t, ok, "Migration file has unexpected format: %s", filename)
version, err := strconv.ParseInt(versionString, 10, 64)
require.NoErrorf(t, err, "Failed to parse version from filename '%s'", filename)
// Exclude migrations with version below ignoreBefore
if version < ignoreBefore {
continue
}
versions = append(versions, version)
}
return versions
}

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