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
100 changed files with 4175 additions and 3551 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

@@ -2,11 +2,11 @@ name: Run Backend Linter
on:
push:
branches: [main, breaking/**]
branches: [main]
paths:
- "backend/**"
pull_request:
branches: [main, breaking/**]
branches: [main]
paths:
- "backend/**"

View File

@@ -1,13 +1,13 @@
name: E2E Tests
on:
push:
branches: [main, breaking/**]
branches: [main]
paths-ignore:
- "docs/**"
- "**.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,73 +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 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
@@ -166,20 +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 &
export FILE_BACKEND="${{ matrix.storage }}"
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
- 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 &
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
docker compose -f "$DOCKER_COMPOSE_FILE" logs -f pocket-id &> /tmp/backend.log &
- name: Run Docker Container (sqlite-s3) with LDAP + S3
if: matrix.db == 'sqlite-s3'
working-directory: ./tests/setup
run: |
docker compose -f docker-compose-s3.yml up -d
docker compose -f docker-compose-s3.yml logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: ./tests
@@ -189,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
@@ -198,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

View File

@@ -2,7 +2,7 @@ name: Svelte Check
on:
push:
branches: [main, breaking/**]
branches: [main]
paths:
- "frontend/src/**"
- ".github/svelte-check-matcher.json"

View File

@@ -1,7 +1,7 @@
name: Unit Tests
on:
push:
branches: [main, breaking/**]
branches: [main]
paths:
- "backend/**"
pull_request:

1
.gitignore vendored
View File

@@ -25,6 +25,7 @@ Thumbs.db
.env.*
!.env.development-example
!.env.test
!.env.example
# Vite
vite.config.js.timestamp-*

View File

@@ -1 +1 @@
1.16.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,44 +1,3 @@
## 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

@@ -3,10 +3,10 @@ module github.com/pocket-id/pocket-id/backend
go 1.25
require (
github.com/aws/aws-sdk-go-v2 v1.40.0
github.com/aws/aws-sdk-go-v2/config v1.32.2
github.com/aws/aws-sdk-go-v2/credentials v1.19.2
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1
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
@@ -15,14 +15,14 @@ require (
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-contrib/slog v1.2.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.18.1
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.28.0
github.com/go-webauthn/webauthn v0.15.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
@@ -34,7 +34,7 @@ require (
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.0
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.13.0
@@ -48,35 +48,34 @@ require (
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.45.0
golang.org/x/image v0.33.0
golang.org/x/sync v0.18.0
golang.org/x/text v0.31.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/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.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14 // 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.14 // 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.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2 // 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.1 // indirect
@@ -84,21 +83,20 @@ require (
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/gabriel-vasile/mimetype v1.4.11 // 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.26 // 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.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.1.0 // indirect
github.com/google/go-tpm v0.9.7 // 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
@@ -130,17 +128,17 @@ require (
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.57.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/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
@@ -156,22 +154,22 @@ require (
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.9.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-20251125195548-87e1e737ad39 // indirect
golang.org/x/mod v0.30.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/oauth2 v0.33.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/tools v0.39.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 // indirect
google.golang.org/grpc v1.77.0 // 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.1 // 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.40.1 // indirect
modernc.org/sqlite v1.39.1 // indirect
)

View File

@@ -2,76 +2,44 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
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/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/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.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 v1.40.0 h1:/WMUA0kjhZExjOQN2z3oLALDREea1A7TobfuiBrKlwc=
github.com/aws/aws-sdk-go-v2 v1.40.0/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/config v1.32.2 h1:4liUsdEpUUPZs5WVapsJLx5NPmQhQdez7nYFcovrytk=
github.com/aws/aws-sdk-go-v2/config v1.32.2/go.mod h1:l0hs06IFz1eCT+jTacU/qZtC33nvcnLADAPL/XyrkZI=
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/credentials v1.19.2 h1:qZry8VUyTK4VIo5aEdUcBjPZHL2v4FyQ3QEOaWcFLu4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.2/go.mod h1:YUqm5a1/kBnoK+/NY5WEiMocZihKSo15/tJdmdXnM5g=
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/feature/ec2/imds v1.18.14 h1:WZVR5DbDgxzA0BJeudId89Kmgy6DIU4ORpxwsVHz0qA=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.14/go.mod h1:Dadl9QO0kHgbrH1GRqGiZdYtW5w+IXXaBNCHTIaheM4=
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/configsources v1.4.14 h1:PZHqQACxYb8mYgms4RZbhZG0a7dPW06xOjmaH0EJC/I=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.14/go.mod h1:VymhrMJUWs69D8u0/lZ7jSB6WgaG/NqHi3gX0aYf6U0=
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/endpoints/v2 v2.7.14 h1:bOS19y6zlJwagBfHxs0ESzr1XCOU2KXJCWcq3E2vfjY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.14/go.mod h1:1ipeGBMAxZ0xcTm6y6paC2C/J6f6OO7LBODV9afuAyM=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk=
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 h1:eg/WYAa12vqTphzIdWMzqYRVKKnCboVPRlvaybNCqPA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13/go.mod h1:/FDdxWhz1486obGrKKC1HONd7krpk38LBt+dutLcN9k=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 h1:ITi7qiDSv/mSGDSWNpZ4k4Ve0DQR6Ug2SJQ8zEHoDXg=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14/go.mod h1:k1xtME53H1b6YpZt74YmwlONMWf4ecM+lut1WQLAF/U=
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/checksum v1.9.5 h1:Hjkh7kE6D81PgrHlE/m9gx+4TyyeLHuY8xJs7yXN5C4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.5/go.mod h1:nPRXgyCfAurhyaTMoBMwRBYBhaHI4lNPAnJmjM0Tslc=
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/presigned-url v1.13.14 h1:FIouAnCE46kyYqyhs0XEBDFFSREtdnr8HQuLPQPLCrY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.14/go.mod h1:UTwDc5COa5+guonQU8qBikJo1ZJ4ln2r1MkF7Dqag1E=
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/internal/s3shared v1.19.14 h1:FzQE21lNtUor0Fb7QNgnEyiRCBlolLTX/Z1j65S7teM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.14/go.mod h1:s1ydyWG9pm3ZwmmYN21HKyG9WzAZhYVW85wMHs5FV6w=
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/s3 v1.92.1 h1:OgQy/+0+Kc3khtqiEOk23xQAglXi3Tj0y5doOxbi5tg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.92.1/go.mod h1:wYNqY3L02Z3IgRYxOBPH9I1zD9Cjh9hI5QOy/eOjQvw=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2 h1:MxMBdKTYBjPQChlJhi4qlEueqB1p1KcbTEa7tD5aqPs=
github.com/aws/aws-sdk-go-v2/service/signin v1.0.2/go.mod h1:iS6EPmNeqCsGo+xQmXv0jIMjyYtQfnwg36zl2FwEouk=
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/sso v1.30.5 h1:ksUT5KtgpZd3SAiFJNJ0AFEJVva3gjBmN7eXUZjzUwQ=
github.com/aws/aws-sdk-go-v2/service/sso v1.30.5/go.mod h1:av+ArJpoYf3pgyrj6tcehSFW+y9/QvAY8kMooR9bZCw=
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/ssooidc v1.35.10 h1:GtsxyiF3Nd3JahRBJbxLCCdYW9ltGQYrFWg8XdkGDd8=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.10/go.mod h1:/j67Z5XBVDx8nZVp9EuFM9/BS5dvBznbqILGuu73hug=
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/aws-sdk-go-v2/service/sts v1.41.2 h1:a5UTtD4mHBU3t0o6aHQZFJTNKVfxFWfPX7J0Lr7G+uY=
github.com/aws/aws-sdk-go-v2/service/sts v1.41.2/go.mod h1:6TxbXoDSgBQ225Qd8Q+MbxUxUh6TtNKwbRt/EPS9xso=
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=
@@ -80,12 +48,8 @@ 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.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
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.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
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/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=
@@ -133,12 +97,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
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/gabriel-vasile/mimetype v1.4.11 h1:AQvxbp830wPhHTqc1u7nzoLT+ZFxGY7emj5DR5DYFik=
github.com/gabriel-vasile/mimetype v1.4.11/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/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
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=
@@ -151,8 +111,6 @@ github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ
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.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-co-op/gocron/v2 v2.18.1 h1:VVxgAghLW1Q6VHi/rc+B0ZSpFoUVlWgkw09Yximvn58=
github.com/go-co-op/gocron/v2 v2.18.1/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=
@@ -168,16 +126,10 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
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-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.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/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.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/go-webauthn/x v0.1.26 h1:eNzreFKnwNLDFoywGh9FA8YOMebBWTUNlNSdolQRebs=
github.com/go-webauthn/x v0.1.26/go.mod h1:jmf/phPV6oIsF6hmdVre+ovHkxjDOmNH0t6fekWUxvg=
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.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
@@ -202,8 +154,6 @@ github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
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/go-tpm v0.9.7 h1:u89J4tUUeDTlH8xxC3CTW7OHZjbjKoHdQ9W7gCUhtxA=
github.com/google/go-tpm v0.9.7/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=
@@ -315,8 +265,6 @@ github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsT
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
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/oschwald/maxminddb-golang/v2 v2.1.0 h1:2Iv7lmG9XtxuZA/jFAsd7LnZaC1E59pFsj5O/nU15pw=
github.com/oschwald/maxminddb-golang/v2 v2.1.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=
@@ -329,22 +277,14 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.1 h1:OTSON1P4DNxzTg4hmKCc37o4ZAZDv0cfXLkOt0oEowI=
github.com/prometheus/common v0.67.1/go.mod h1:RpmT9v35q2Y+lsieQsdOh5sXZ6ajUGC8NjZAmr8vb0Q=
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/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.18.0 h1:2QTA9cKdznfYJz7EDaa7IiJobHuV7E1WzeBwcrhk0ao=
github.com/prometheus/procfs v0.18.0/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
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.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
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.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
github.com/quic-go/quic-go v0.57.0 h1:AsSSrrMs4qI/hLrKlTH/TGQeTMY0ib1pAOX7vA3AdqE=
github.com/quic-go/quic-go v0.57.0/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
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=
@@ -362,23 +302,18 @@ github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3A
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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
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.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
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.4 h1:uAUNq9Z6ymTgGhcm0UynUAB6tlbakBrz6CQFax3BXVQ=
github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
@@ -437,8 +372,6 @@ go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJr
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.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.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=
@@ -447,71 +380,53 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.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.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
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/exp v0.0.0-20251125195548-87e1e737ad39 h1:DHNhtq3sNNzrvduZZIiFyXWOL9IWaDPHqTnLJp+rCBY=
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39/go.mod h1:46edojNIoXTNOhySWIWdix628clX9ODXwPsQuG6hsK0=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
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/image v0.33.0 h1:LXRZRnv1+zGd5XBUVRFmYEphyyKJjQjCRiOuAP3sZfQ=
golang.org/x/image v0.33.0/go.mod h1:DD3OsTYT9chzuzTQt+zMcOlBHgfoKQb1gry8p76Y1sc=
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/mod v0.30.0 h1:fDEXFVZ/fmCKProc/yAXXUijritrDzahmwwefnjoPFk=
golang.org/x/mod v0.30.0/go.mod h1:lAsf5O2EvJeSFMiBxXDki7sCgAxEUcZHXoXMKT4GJKc=
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.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
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.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY=
golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/oauth2 v0.33.0 h1:4Q+qn+E5z8gPRJfmRy7C2gGG3T4jIprK6aSYgTXGRpo=
golang.org/x/oauth2 v0.33.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.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.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
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.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/tools v0.39.0 h1:ik4ho21kwuQln40uelmciQPp9SipgNDdrafrYA4TmQQ=
golang.org/x/tools v0.39.0/go.mod h1:JnefbkDPyD8UU2kI5fuf8ZX4/yUeh9W877ZeBONxUqQ=
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-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/api v0.0.0-20251124214823-79d6a2a48846 h1:ZdyUkS9po3H7G0tuh955QVyyotWvOD4W0aEapeGeUYk=
google.golang.org/genproto/googleapis/api v0.0.0-20251124214823-79d6a2a48846/go.mod h1:Fk4kyraUvqD7i5H6S43sj2W98fbZa75lpZz/eUyhfO0=
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/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846 h1:Wgl1rcDNThT+Zn47YyCXOXyX/COgMTIdhJ717F0l4xk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251124214823-79d6a2a48846/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/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
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=
@@ -524,14 +439,10 @@ 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.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
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.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
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/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
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=
@@ -540,8 +451,6 @@ modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/libc v1.67.1 h1:bFaqOaa5/zbWYJo8aW0tXPX21hXsngG2M7mckCnFSVk=
modernc.org/libc v1.67.1/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
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=
@@ -552,8 +461,6 @@ modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/sqlite v1.40.1 h1:VfuXcxcUWWKRBuP8+BR9L7VnmusMgBNNnBYGEe9w/iY=
modernc.org/sqlite v1.40.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

@@ -22,37 +22,28 @@ func Bootstrap(ctx context.Context) error {
}
slog.InfoContext(ctx, "Pocket ID is starting")
// Connect to the database
db, err := NewDatabase()
if err != nil {
return fmt.Errorf("failed to initialize database: %w", err)
}
// Initialize the file storage backend
var fileStorage storage.FileStorage
switch common.EnvConfig.FileBackend {
case storage.TypeFileSystem:
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
case storage.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,
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)
@@ -60,6 +51,12 @@ 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 {

View File

@@ -155,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 {
@@ -394,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

@@ -41,11 +41,11 @@ func initRouter(db *gorm.DB, svc *services) utils.Service {
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)
}
@@ -63,8 +63,6 @@ func initRouterInternal(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())
@@ -94,7 +92,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewVersionController(apiGroup, svc.versionService)
// 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)
}
@@ -112,17 +110,7 @@ func initRouterInternal(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

View File

@@ -66,7 +66,7 @@ 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.versionService = service.NewVersionService(httpClient)

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,39 +34,38 @@ const (
)
type EnvConfigSchema struct {
AppEnv AppEnv `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"`
S3DisableDefaultIntegrityChecks bool `env:"S3_DISABLE_DEFAULT_INTEGRITY_CHECKS"`
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"`
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()
@@ -85,10 +80,10 @@ func init() {
func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{
AppEnv: AppEnvProduction,
AppEnv: "production",
LogLevel: "info",
DbProvider: "sqlite",
FileBackend: "filesystem",
FileBackend: "fs",
KeysPath: "data/keys",
AppURL: AppUrl,
Port: "1411",
@@ -185,14 +180,12 @@ func validateEnvConfig(config *EnvConfigSchema) error {
if config.KeysStorage == "file" {
return errors.New("KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
}
case "database":
// All good, these are valid values
case "", "filesystem":
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
@@ -293,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

@@ -192,7 +192,7 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "PRODUCTION")
t.Setenv("APP_ENV", "STAGING")
t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080")
@@ -203,7 +203,7 @@ func TestParseEnvConfig(t *testing.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
@@ -214,12 +214,12 @@ func TestParseEnvConfig(t *testing.T) {
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 := parseEnvConfig()
require.NoError(t, err)
assert.Equal(t, "filesystem", EnvConfig.FileBackend)
assert.Equal(t, "fs", EnvConfig.FileBackend)
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
})
@@ -279,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

@@ -587,6 +587,7 @@ func (oc *OidcController) updateClientLogoHandler(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
// deleteClientLogoHandler godoc
@@ -613,6 +614,7 @@ func (oc *OidcController) deleteClientLogoHandler(c *gin.Context) {
}
c.Status(http.StatusNoContent)
}
// updateAllowedUserGroupsHandler godoc

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -426,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
}
@@ -446,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
}

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,13 +329,13 @@ 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
@@ -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,6 +12,7 @@ import (
"io"
"log/slog"
"mime/multipart"
"net"
"net/http"
"net/url"
"path"
@@ -678,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
}
@@ -725,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,
@@ -733,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
@@ -744,65 +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)
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
}
@@ -835,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
}
@@ -952,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()
@@ -1004,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).
@@ -1017,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
}
@@ -1038,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
}
@@ -1821,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
}
@@ -1966,25 +1976,7 @@ func (s *OidcService) IsClientAccessibleToUser(ctx context.Context, clientID str
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
@@ -1993,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 {
@@ -2024,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
}
@@ -2036,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
@@ -2056,70 +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
var column string
if light {
column = "image_type"
} else {
column = "dark_image_type"
}
return tx.WithContext(ctx).
Model(&model.OidcClient{}).
Where("id = ?", clientID).
Update(column, ext).
Error
}

View File

@@ -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"
)
@@ -541,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

@@ -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)
@@ -298,27 +286,16 @@ func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User,
// 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)
}
}
@@ -326,15 +303,12 @@ func (s *UserService) applySignupDefaults(ctx context.Context, user *model.User,
// 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)
}
}
@@ -371,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 {
@@ -443,11 +416,13 @@ func (s *UserService) RequestOneTimeAccessEmailAsUnauthenticatedUser(ctx context
var userId string
err := s.db.Model(&model.User{}).Select("id").Where("email = ?", userID).First(&userId).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
if err != nil {
// Do not return error if user not found to prevent email enumeration
return nil
} else if err != nil {
return err
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
} else {
return err
}
}
return s.requestOneTimeAccessEmailInternal(ctx, userId, redirectPath, 15*time.Minute)
@@ -538,9 +513,7 @@ func (s *UserService) ExchangeOneTimeAccessToken(ctx context.Context, token stri
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 {
@@ -706,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{}).
@@ -747,7 +720,6 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
err := tx.
WithContext(ctx).
Where("token = ?", signupData.Token).
Clauses(clause.Locking{Strength: "UPDATE"}).
First(&signupToken).
Error
if err != 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

@@ -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

@@ -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
}

File diff suppressed because one or more lines are too long

View File

@@ -1,9 +0,0 @@
-- The "storage" table contains file data stored in the database
CREATE TABLE storage
(
path TEXT NOT NULL PRIMARY KEY,
data BYTEA NOT NULL,
size BIGINT NOT NULL,
mod_time TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ NOT NULL
);

View File

@@ -1 +0,0 @@
DROP INDEX idx_api_keys_expires_at;

View File

@@ -1 +0,0 @@
CREATE INDEX idx_api_keys_expires_at ON api_keys(expires_at);

View File

@@ -1,6 +0,0 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE storage;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,14 +0,0 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- The "storage" table contains file data stored in the database
CREATE TABLE storage
(
path TEXT NOT NULL PRIMARY KEY,
data BLOB NOT NULL,
size INTEGER NOT NULL,
mod_time DATETIME NOT NULL,
created_at DATETIME NOT NULL
);
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,5 +0,0 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP INDEX idx_api_keys_expires_at;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,5 +0,0 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE INDEX idx_api_keys_expires_at ON api_keys(expires_at);
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -9,17 +9,17 @@
"export": "email export"
},
"dependencies": {
"@react-email/components": "1.0.1",
"@react-email/components": "0.1.1",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.3.1"
},
"devDependencies": {
"@react-email/preview-server": "5.0.5",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"react-email": "5.0.5",
"@react-email/preview-server": "4.2.8",
"@types/node": "^24.9.1",
"@types/react": "^19.2.2",
"@types/react-dom": "^19.2.2",
"react-email": "4.2.8",
"tsx": "^4.20.6"
}
}

View File

@@ -3,6 +3,6 @@
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilový obrázek",
"profile_picture_is_managed_by_ldap_server": "Profilový obrázek je spravován LDAP serverem a nelze jej zde změnit.",
"click_profile_picture_to_upload_custom": "Klikněte na profilový obrázek pro nahrání vlastního ze souborů.",
"image_should_be_in_format": "Obrázek by měl být ve formátu PNG, JPEG nebo WEBP.",
"image_should_be_in_format": "Obrázek by měl být ve formátu PNG nebo JPEG.",
"items_per_page": "Položek na stránku",
"no_items_found": "Nenalezeny žádné položky",
"select_items": "Vyberte položky...",
@@ -331,10 +331,6 @@
"token_sign_in": "Přihlášení tokenem",
"client_authorization": "Autorizace klienta",
"new_client_authorization": "Nová autorizace klienta",
"device_code_authorization": "Autorizace kódu zařízení",
"new_device_code_authorization": "Autorizace nového kódu zařízení",
"passkey_added": "Přidán přístupový klíč",
"passkey_removed": "Heslo odstraněno",
"disable_animations": "Zakázat animace",
"turn_off_ui_animations": "Vypnout animace v celém uživatelském rozhraní.",
"user_disabled": "Účet deaktivován",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilbillede",
"profile_picture_is_managed_by_ldap_server": "Profilbilledet administreres af LDAP-serveren og kan ikke ændres her.",
"click_profile_picture_to_upload_custom": "Klik på profilbilledet for at uploade et brugerdefineret billede fra dine filer.",
"image_should_be_in_format": "Billedet skal være i PNG, JPEG eller WEBP-format.",
"image_should_be_in_format": "Billedet skal være i PNG eller JPEG-format.",
"items_per_page": "Emner pr. side",
"no_items_found": "Ingen emner fundet",
"select_items": "Vælg emner...",
@@ -331,10 +331,6 @@
"token_sign_in": "Token-login",
"client_authorization": "Godkendelse af klient",
"new_client_authorization": "Ny klientgodkendelse",
"device_code_authorization": "Autorisation af enhedskode",
"new_device_code_authorization": "Autorisation af ny enhedskode",
"passkey_added": "Passkey tilføjet",
"passkey_removed": "Adgangskode fjernet",
"disable_animations": "Deaktiver animationer",
"turn_off_ui_animations": "Slå animationer fra for hele brugergrænsefladen.",
"user_disabled": "Konto deaktiveret",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilbild",
"profile_picture_is_managed_by_ldap_server": "Das Profilbild wird vom LDAP-Server verwaltet und kann hier nicht geändert werden.",
"click_profile_picture_to_upload_custom": "Klicke auf das Profilbild, um ein benutzerdefiniertes Bild aus deinen Dateien hochzuladen.",
"image_should_be_in_format": "Das Bild sollte im PNG-, JPEG- oder WEBP-Format vorliegen.",
"image_should_be_in_format": "Das Bild sollte im PNG- oder JPEG-Format vorliegen.",
"items_per_page": "Einträge pro Seite",
"no_items_found": "Keine Einträge gefunden",
"select_items": "Elemente auswählen...",
@@ -105,7 +105,7 @@
"unknown": "unbekannt",
"account_details_updated_successfully": "Kontodetails erfolgreich aktualisiert",
"profile_picture_updated_successfully": "Profilbild erfolgreich aktualisiert. Die Aktualisierung kann einige Minuten dauern.",
"account_settings": "Konto-Einstellungen",
"account_settings": "Konto Einstellungen",
"passkey_missing": "Passkey fehlt",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Bitte füge einen Passkey hinzu, um zu verhindern, dass du den Zugriff auf dein Konto verlierst.",
"single_passkey_configured": "Nur ein Passkey hinterlegt",
@@ -331,10 +331,6 @@
"token_sign_in": "Token-Anmeldung",
"client_authorization": "Client-Autorisierung",
"new_client_authorization": "Neue Client-Autorisierung",
"device_code_authorization": "Gerätecode-Autorisierung",
"new_device_code_authorization": "Neue Gerätecode-Autorisierung",
"passkey_added": "Passwort hinzugefügt",
"passkey_removed": "Passwort entfernt",
"disable_animations": "Animationen deaktivieren",
"turn_off_ui_animations": "Deaktiviert alle Animationen in der Benutzeroberfläche.",
"user_disabled": "Account deaktiviert",
@@ -443,7 +439,7 @@
"client_launch_url_description": "Die URL, die geöffnet wird, wenn jemand die App von der Seite „Meine Apps“ startet.",
"client_name_description": "Der Name des Clients, der in der Pocket ID-Benutzeroberfläche angezeigt wird.",
"revoke_access": "Zugriff widerrufen",
"revoke_access_description": "Zugriff auf <b>{clientName}</b> widerrufen. <b>{clientName}</b> kann nicht mehr auf deine Kontoinformationen zugreifen.",
"revoke_access_description": "Zugriff widerrufen <b>{clientName}</b>. <b>{clientName}</b> kann nicht mehr auf deine Kontoinfos zugreifen.",
"revoke_access_successful": "Der Zugriff auf „ {clientName} “ wurde erfolgreich gesperrt.",
"last_signed_in_ago": "Zuletzt angemeldet vor {time} Stunden",
"invalid_client_id": "Die Kunden-ID darf nur Buchstaben, Zahlen, Unterstriche und Bindestriche haben.",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profile Picture",
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
"image_should_be_in_format": "The image should be in PNG, JPEG or WEBP format.",
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"select_items": "Select items...",
@@ -331,10 +331,6 @@
"token_sign_in": "Token Sign In",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"device_code_authorization": "Device Code Authorization",
"new_device_code_authorization": "New Device Code Authorization",
"passkey_added": "Passkey Added",
"passkey_removed": "Passkey Removed",
"disable_animations": "Disable Animations",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "Account Disabled",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Foto de perfil",
"profile_picture_is_managed_by_ldap_server": "La imagen de perfil es administrada por el servidor LDAP y no puede ser cambiada aquí.",
"click_profile_picture_to_upload_custom": "Haga clic en la imagen de perfil para subir una personalizada desde sus archivos.",
"image_should_be_in_format": "La imagen debe ser en formato PNG, JPEG o WEBP.",
"image_should_be_in_format": "La imagen debe ser en formato PNG o JPEG.",
"items_per_page": "Elementos por página",
"no_items_found": "No se encontraron elementos",
"select_items": "Seleccionar elementos...",
@@ -331,10 +331,6 @@
"token_sign_in": "Inicio de sesión con token",
"client_authorization": "Autorización del cliente",
"new_client_authorization": "Autorización de nuevo cliente",
"device_code_authorization": "Autorización del código del dispositivo",
"new_device_code_authorization": "Autorización de código de nuevo dispositivo",
"passkey_added": "Clave de acceso añadida",
"passkey_removed": "Clave de acceso eliminada",
"disable_animations": "Desactivar animaciones",
"turn_off_ui_animations": "Desactiva las animaciones en toda la interfaz de usuario.",
"user_disabled": "Cuenta desactivada",

View File

@@ -1,473 +0,0 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Oma tIli",
"logout": "Kirjaudu ulos",
"confirm": "Vahvista",
"docs": "Ohjeet",
"key": "Avain",
"value": "Arvo",
"remove_custom_claim": "Poista mukautettu vaatimus",
"add_custom_claim": "Lisää mukautettu vaatimus",
"add_another": "Lisää toinen",
"select_a_date": "Valitse päivämäärä",
"select_file": "Valitse tiedosto",
"profile_picture": "Profiilikuva",
"profile_picture_is_managed_by_ldap_server": "Profiilikuva hallitaan LDAP-palvelimella, eikä sitä voi muuttaa tässä.",
"click_profile_picture_to_upload_custom": "Napsauta profiilikuvaa ladataksesi kuvan tiedostoistasi.",
"image_should_be_in_format": "Kuvan tulee olla PNG-, JPEG- tai WEBP-muodossa.",
"items_per_page": "Kohteita per sivu",
"no_items_found": "Kohteita ei löytynyt",
"select_items": "Valitse kohteet...",
"search": "Hae...",
"expand_card": "Laajenna kortti",
"copied": "Kopioitu",
"click_to_copy": "Klikkaa kopioidaksesi",
"something_went_wrong": "Jokin meni pieleen",
"go_back_to_home": "Siirry takaisin kotiin",
"alternative_sign_in_methods": "Vaihtoehtoiset kirjautumistavat",
"login_background": "Kirjautumisen tausta",
"logo": "Logo",
"login_code": "Kirjautumiskoodi",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Luo kirjautumiskoodi, jota käyttäjä voi käyttää kirjautuakseen sisään ilman pääsyavainta kerran.",
"one_hour": "1 tunti",
"twelve_hours": "12 tuntia",
"one_day": "1 päivä",
"one_week": "1 viikko",
"one_month": "1 kuukausi",
"expiration": "Vanhentuminen",
"generate_code": "Luo koodi",
"name": "Nimi",
"browser_unsupported": "Selainta ei tueta",
"this_browser_does_not_support_passkeys": "Tämä selain ei tue pääsyavaimia. Käytä vaihtoehtoista kirjautumistapaa.",
"an_unknown_error_occurred": "Tapahtui tuntematon virhe",
"authentication_process_was_aborted": "Todentamisprosessi keskeytettiin",
"error_occurred_with_authenticator": "Todentajan kanssa tapahtui virhe",
"authenticator_does_not_support_discoverable_credentials": "Todentaja ei tue löydettäviä käyttäjätietoja",
"authenticator_does_not_support_resident_keys": "Todentaja ei tue laiteavaimia",
"passkey_was_previously_registered": "Tämä pääsyavain on aiemmin rekisteröity",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Todentaja ei tue mitään pyydetyistä algoritmeista",
"authenticator_timed_out": "Todentaja aikakatkaistiin",
"critical_error_occurred_contact_administrator": "Kriittinen virhe tapahtui. Ota yhteyttä järjestelmänvalvojaan.",
"sign_in_to": "Kirjaudu palveluun {name}",
"client_not_found": "Asiakasta ei löydy",
"client_wants_to_access_the_following_information": "<b>{client}</b> haluaa käyttää seuraavia tietoja:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Haluatko kirjautua sisään palveluun <b>{client}</b> {appName} -tililläsi?",
"email": "Sähköposti",
"view_your_email_address": "Näytä sähköpostiosoitteesi",
"profile": "Profiili",
"view_your_profile_information": "Tarkastele profiilisi tietoja",
"groups": "Ryhmät",
"view_the_groups_you_are_a_member_of": "Tarkastele ryhmiä, joiden jäsen olet",
"cancel": "Peruuta",
"sign_in": "Kirjaudu sisään",
"try_again": "Yritä uudelleen",
"client_logo": "Asiakasohjelman Logo",
"sign_out": "Kirjaudu ulos",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Haluatko kirjautua ulos palvelusta {appName} tilillä <b>{username}</b>?",
"sign_in_to_appname": "Kirjaudu palveluun {appName}",
"please_try_to_sign_in_again": "Yritä kirjautua sisään uudelleen.",
"authenticate_with_passkey_to_access_account": "Tunnistaudu pääsyavaimellasi, jotta pääset tiliisi.",
"authenticate": "Tunnistaudu",
"please_try_again": "Ole hyvä ja yritä uudelleen.",
"continue": "Jatka",
"alternative_sign_in": "Vaihtoehtoinen kirjautuminen",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Jos sinulla ei ole pääsyä pääsyavaimeesi, voit kirjautua sisään jollakin seuraavista tavoista.",
"use_your_passkey_instead": "Käytä pääsyavainta sittenkin?",
"email_login": "Sisäänkirjautuminen sähköpostilla",
"enter_a_login_code_to_sign_in": "Syötä kirjautumiskoodi kirjautuaksesi sisään.",
"sign_in_with_login_code": "Kirjaudu sisään kirjautumiskoodilla",
"request_a_login_code_via_email": "Pyydä kirjautumiskoodi sähköpostitse.",
"go_back": "Takaisin",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Sähköposti on lähetetty annettuun osoitteeseen, jos se on järjestelmässä.",
"enter_code": "Syötä koodi",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Syötä sähköpostiosoitteesi saadaksesi kirjautumiskoodin sähköpostitse.",
"your_email": "Sähköpostisi",
"submit": "Lähetä",
"enter_the_code_you_received_to_sign_in": "Syötä saamasi koodi kirjautuaksesi sisään.",
"code": "Koodi",
"invalid_redirect_url": "Virheellinen uudelleenohjauksen URL",
"audit_log": "Tarkastusloki",
"users": "Käyttäjät",
"user_groups": "Käyttäjäryhmät",
"oidc_clients": "OIDC Asiakkaat",
"api_keys": "API Avaimet",
"application_configuration": "Sovelluksen määritys",
"settings": "Asetukset",
"update_pocket_id": "Päivitä Pocket ID",
"powered_by": "Voimanlähteenä",
"see_your_account_activities_from_the_last_3_months": "Katso tilisi tapahtumat viimeisen 3 kuukauden ajalta.",
"time": "Aika",
"event": "Tapahtuma",
"approximate_location": "Arvioitu sijainti",
"ip_address": "IP-osoite",
"device": "Laite",
"client": "Asiakas",
"unknown": "Tuntematon",
"account_details_updated_successfully": "Tilin tiedot päivitetty onnistuneesti",
"profile_picture_updated_successfully": "Profiilikuva päivitetty onnistuneesti. Päivitys voi kestää muutaman minuutin.",
"account_settings": "Tilin asetukset",
"passkey_missing": "Pääsyavain puuttuu",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Lisää pääsyavain, jotta et menetä pääsyä tiliisi.",
"single_passkey_configured": "Yksi pääsyavain määritetty",
"it_is_recommended_to_add_more_than_one_passkey": "On suositeltavaa lisätä useampi pääsyavain, jottet menetä päsyä tiliisi.",
"account_details": "Tilitiedot",
"passkeys": "Pääsyavaimet",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Hallitse pääsyavaimiasi, joita voit käyttää tunnistautumiseen.",
"add_passkey": "Lisää pääsyavain",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Luo kertakäyttöinen kirjautumiskoodi, jotta voit kirjautua sisään toisella laitteella ilman pääsyavainta.",
"create": "Luo",
"first_name": "Etunimi",
"last_name": "Sukunimi",
"username": "Käyttäjätunnus",
"save": "Tallenna",
"username_can_only_contain": "Käyttäjätunnus voi sisältää vain pieniä kirjaimia, numeroita, alaviivoja, pisteitä, tavuviivoja ja @-merkkejä",
"username_must_start_with": "Käyttäjätunnuksen on alettava kirjaimella tai numerolla",
"username_must_end_with": "Käyttäjätunnuksen tulee päättyä kirjaimeen tai numeroon",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Kirjaudu sisään seuraavalla koodilla. Koodi vanhenee 15 minuutin kuluttua.",
"or_visit": "tai vieraile",
"added_on": "Lisätty",
"rename": "Nimeä uudelleen",
"delete": "Poista",
"are_you_sure_you_want_to_delete_this_passkey": "Haluatko varmasti poistaa tämän pääsyavaimen?",
"passkey_deleted_successfully": "Pääsyavaimen poistettu onnistuneesti",
"delete_passkey_name": "Poista {passkeyName}",
"passkey_name_updated_successfully": "Pääsyavaimen nimi päivitetty onnistuneesti",
"name_passkey": "Nimeä pääsyavain",
"name_your_passkey_to_easily_identify_it_later": "Nimeä pääsyavaimesi, jotta voit tunnistaa sen helposti myöhemmin.",
"create_api_key": "Luo API-avain",
"add_a_new_api_key_for_programmatic_access": "Lisää uusi API-avain ohjelmoitua pääsyä varten <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>:iin.",
"add_api_key": "Lisää API-avain",
"manage_api_keys": "Hallitse API-avaimia",
"api_key_created": "API-avain luotu",
"for_security_reasons_this_key_will_only_be_shown_once": "Turvallisuussyistä tämä avain näytetään vain kerran. Säilytä se turvallisessa paikassa.",
"description": "Kuvaus",
"api_key": "API-avain",
"close": "Sulje",
"name_to_identify_this_api_key": "Nimi tämän API avaimen tunnistamiseksi.",
"expires_at": "Vanhenee",
"when_this_api_key_will_expire": "Milloin tämä API-avain vanhenee.",
"optional_description_to_help_identify_this_keys_purpose": "Valinnainen kuvaus, joka auttaa tunnistamaan tämän avaimen tarkoituksen.",
"expiration_date_must_be_in_the_future": "Päättymispäivän on oltava tulevaisuudessa",
"revoke_api_key": "Peruuta API-avain",
"never": "Ei koskaan",
"revoke": "Peru",
"api_key_revoked_successfully": "API-avain peruttu onnistuneesti",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Haluatko varmasti perua API-avaimen \"{apiKeyName}\"? Tämä katkaisee kaikki tämän avaimen käyttävät integraatiot.",
"last_used": "Viimeksi käytetty",
"actions": "Toiminnot",
"images_updated_successfully": "Kuvat päivitetty onnistuneesti. Päivitys voi kestää muutaman minuutin.",
"general": "Yleiset",
"configure_smtp_to_send_emails": "Ota käyttöön sähköposti-ilmoitukset ilmoittaaksesi käyttäjille, kun kirjautuminen havaitaan uudesta laitteesta tai sijainnista.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Määritä LDAP-asetukset käyttäjien ja ryhmien synkronointia varten LDAP-palvelimelta.",
"images": "Kuvat",
"update": "Päivitä",
"email_configuration_updated_successfully": "Sähköpostiasetukset päivitetty onnistuneesti",
"save_changes_question": "Tallenna muutokset?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Sinun on tallennettava muutokset ennen testisähköpostin lähettämistä. Haluatko tallentaa nyt?",
"save_and_send": "Tallenna ja lähetä",
"test_email_sent_successfully": "Testiviesti lähetetty onnistuneesti sähköpostiosoitteeseesi.",
"failed_to_send_test_email": "Testisähköpostin lähettäminen epäonnistui. Tarkista lisätietoja palvelimen lokitiedostoista.",
"smtp_configuration": "SMTP Asetukset",
"smtp_host": "SMTP palvelin",
"smtp_port": "SMTP portti",
"smtp_user": "SMTP käyttäjä",
"smtp_password": "SMTP salasana",
"smtp_from": "SMTP lähettäjä",
"smtp_tls_option": "SMTP TLS -valinta",
"email_tls_option": "Sähköpostin TLS-valinta",
"skip_certificate_verification": "Ohita varmenteen vahvistus",
"this_can_be_useful_for_selfsigned_certificates": "Tämä voi olla hyödyllistä itse-allekirjoitetuille varmenteille.",
"enabled_emails": "Käytössä olevat sähköpostit",
"email_login_notification": "Sähköposti-ilmoitus kirjautumisesta",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Lähetä käyttäjälle sähköposti, kun hän kirjautuu sisään uudelta laitteelta.",
"emai_login_code_requested_by_user": "Käyttäjän pyytämä sähköpostin kirjautumiskoodi",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Antaa käyttäjille mahdollisuuden ohittaa pääsyavaimen pyytämällä kirjautumiskoodin, joka lähetetään heidän sähköpostiinsa. Tämä merkittävästi heikentää turvallisuutta, koska kuka tahansa, jolla on pääsy käyttäjän sähköpostiin, voi kirjautua sisään.",
"email_login_code_from_admin": "Sähköpostin kirjautumiskoodi järjestelmänvalvojalta",
"allows_an_admin_to_send_a_login_code_to_the_user": "Antaa järjestelmänvalvojalle mahdollisuuden lähettää käyttäjälle kirjautumiskoodi sähköpostitse.",
"send_test_email": "Lähetä testisähköposti",
"application_configuration_updated_successfully": "Sovelluksen määritykset päivitetty onnistuneesti",
"application_name": "Sovelluksen nimi",
"session_duration": "Istunnon kesto",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Istunnon kesto minuutteina ennen kuin käyttäjän on kirjauduttava uudelleen.",
"enable_self_account_editing": "Ota käyttöön tilin itsemuokkaus",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Määrittää voiko käyttäjät itse muokata oman tilinsä tietoja.",
"emails_verified": "Sähköpostiosoitteet vahvistettu",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Merkitäänkö käyttäjän sähköpostiosoite vahvistetuksi OIDC-asiakkaille.",
"ldap_configuration_updated_successfully": "LDAP-määritykset päivitetty onnistuneesti",
"ldap_disabled_successfully": "LDAP poistettu käytöstä onnistuneesti",
"ldap_sync_finished": "LDAP-synkronointi valmis",
"client_configuration": "Asiakkaan määritys",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Salasana",
"ldap_base_dn": "LDAP perus DN",
"user_search_filter": "Käyttäjän hakusuodatin",
"the_search_filter_to_use_to_search_or_sync_users": "Käyttäjien hakuun/synkronointiin käytettävä hakusuodatin.",
"groups_search_filter": "Ryhmien hakusuodatin",
"the_search_filter_to_use_to_search_or_sync_groups": "Ryhmien hakuun/synkronointiin käytettävä hakusuodatin.",
"attribute_mapping": "Attribuuttien yhdistäminen",
"user_unique_identifier_attribute": "Käyttäjän yksilöllinen tunnisteattribuutti",
"the_value_of_this_attribute_should_never_change": "Tämän attribuutin arvon ei tulisi koskaan muuttua.",
"username_attribute": "Käyttäjänimen attribuutti",
"user_mail_attribute": "Käyttäjän sähköpostin attribuutti",
"user_first_name_attribute": "Käyttäjän etunimi-attribuutti",
"user_last_name_attribute": "Käyttäjän sukunimi-attribuutti",
"user_profile_picture_attribute": "Käyttäjän profiilikuva-attribuutti",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Tämän attribuutin arvo voi olla joko URL, binääri tai base64-koodattu kuva.",
"group_members_attribute": "Ryhmän jäsenten attribuutti",
"the_attribute_to_use_for_querying_members_of_a_group": "Attribuutti, jota käytetään ryhmän jäsenten kyselyä varten.",
"group_unique_identifier_attribute": "Ryhmän yksilöllinen tunnisteattribuutti",
"group_rdn_attribute": "Ryhmän RDN-attribuutti (DN:ssä)",
"admin_group_name": "Järjestelmänvalvojan ryhmän nimi",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Tämän ryhmän jäsenillä on järjestelmänvalvojan oikeudet Pocket ID:ssä.",
"disable": "Poista käytöstä",
"sync_now": "Synkronoi nyt",
"enable": "Ota käyttöön",
"user_created_successfully": "Käyttäjä luotu onnistuneesti",
"create_user": "Luo käyttäjä",
"add_a_new_user_to_appname": "Lisää käyttäjä palveluun {appName}",
"add_user": "Lisää käyttäjä",
"manage_users": "Käyttäjien hallinta",
"admin_privileges": "Järjestelmänvalvojan oikeudet",
"admins_have_full_access_to_the_admin_panel": "Järjestelmänvalvojilla on täysi pääsy hallintapaneeliin.",
"delete_firstname_lastname": "Poista {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Haluatko varmasti poistaa tämän käyttäjän?",
"user_deleted_successfully": "Käyttäjä poistettu onnistuneesti",
"role": "Rooli",
"source": "Lähde",
"admin": "Järjestelmänvalvoja",
"user": "Käyttäjä",
"local": "Paikallinen",
"toggle_menu": "Avaa valikko",
"edit": "Muokkaa",
"user_groups_updated_successfully": "Käyttäjäryhmät päivitetty onnistuneesti",
"user_updated_successfully": "Käyttäjä päivitetty onnistuneesti",
"custom_claims_updated_successfully": "Mukautetut vaatimukset päivitetty onnistuneesti",
"back": "Takaisin",
"user_details_firstname_lastname": "Käyttäjän tiedot {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Hallitse, mihin ryhmiin tämä käyttäjä kuuluu.",
"custom_claims": "Mukautetut vaatimukset",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Mukautetut vaatimukset ovat avain-arvo-pareja, joita voidaan käyttää käyttäjää koskevien lisätietojen tallentamiseen. Nämä vaatimukset sisällytetään tunnistustunnukseen, jos pyydetään oikeusaluetta \"profile\".",
"user_group_created_successfully": "Käyttäjäryhmä luotu onnistuneesti",
"create_user_group": "Luo käyttäjäryhmä",
"create_a_new_group_that_can_be_assigned_to_users": "Luo uusi ryhmä, joka voidaan määrittää käyttäjille.",
"add_group": "Lisää ryhmä",
"manage_user_groups": "Hallitse käyttäjäryhmiä",
"friendly_name": "Käyttäjäystävälinen nimi",
"name_that_will_be_displayed_in_the_ui": "Nimi, joka näkyy käyttöliittymässä",
"name_that_will_be_in_the_groups_claim": "Nimi, joka tulee olemaan \"groups\" -vaatimuksessa",
"delete_name": "Poista {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Haluatko varmasti poistaa tämän käyttäjäryhmän?",
"user_group_deleted_successfully": "Käyttäjäryhmä poistettu onnistuneesti",
"user_count": "Käyttäjien määrä",
"user_group_updated_successfully": "Käyttäjäryhmä päivitetty onnistuneesti",
"users_updated_successfully": "Käyttäjät päivitetty onnistuneesti",
"user_group_details_name": "Käyttäjäryhmän tiedot {name}",
"assign_users_to_this_group": "Määritä käyttäjät tähän ryhmään.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Mukautetut vaatimukset ovat avain-arvo-pareja, joita voidaan käyttää käyttäjää koskevien lisätietojen tallentamiseen. Nämä vaatimukset sisällytetään tunnistustunnukseen, jos pyydetään oikeusaluetta \"profiili\". Käyttäjälle määritellyt mukautetut vaatimukset asetetaan etusijalle, jos esiintyy ristiriitoja.",
"oidc_client_created_successfully": "OIDC-asiakasohjelma luotu onnistuneesti",
"create_oidc_client": "Luo OIDC-asiakas",
"add_a_new_oidc_client_to_appname": "Lisää uusi OIDC-asiakasohjelma {appName} palveluun.",
"add_oidc_client": "Lisää OIDC-asiakas",
"manage_oidc_clients": "Hallitse OIDC-asiakkaita",
"one_time_link": "Kertakäyttöinen linkki",
"use_this_link_to_sign_in_once": "Käytä tätä linkkiä kirjautuaksesi sisään kerran. Tätä tarvitaan käyttäjille, jotka eivät ole vielä lisänneet pääsyavainta tai ovat kadottaneet sen.",
"add": "Lisää",
"callback_urls": "Takaisinkutsu-URL",
"logout_callback_urls": "Uloskirjautumisen takaisinkutsun URL",
"public_client": "Julkinen asiakas",
"public_clients_description": "Julkisilla asiakkailla ei ole asiakassalaisuutta. Ne on suunniteltu mobiili-, web- ja natiivisovelluksiin, joissa salaisuuksia ei voida tallentaa turvallisesti.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange on tietoturvatoiminto, joka estää CSRF:n ja valtuutuskoodin sieppaushyökkäykset.",
"requires_reauthentication": "Vaatii uudelleentodennuksen",
"requires_users_to_authenticate_again_on_each_authorization": "Vaatii käyttäjiltä uuden todennuksen jokaisella valtuutuksella, vaikka he olisivat jo kirjautuneet sisään",
"name_logo": "{name} logo",
"change_logo": "Vaihda logo",
"upload_logo": "Lataa logo",
"remove_logo": "Poista logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Haluatko varmasti poistaa tämän OIDC-asiakkaan?",
"oidc_client_deleted_successfully": "OIDC-asiakasohjelma poistettu onnistuneesti",
"authorization_url": "Valtuutuksen URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Tokenin URL",
"userinfo_url": "Käyttäjätietojen URL-osoite",
"logout_url": "Uloskirjautumisen URL-osoite",
"certificate_url": "Sertifikaatin URL-osoite",
"enabled": "Käytössä",
"disabled": "Pois käytöstä",
"oidc_client_updated_successfully": "OIDC-asiakasohjelma päivitetty onnistuneesti",
"create_new_client_secret": "Luo uusi asiakassalaisuus",
"are_you_sure_you_want_to_create_a_new_client_secret": "Haluatko varmasti luoda uuden asiakassalaisuuden? Vanha salaisuus mitätöidään.",
"generate": "Luo",
"new_client_secret_created_successfully": "Uusi asiakassalaisuus luotu onnistuneesti",
"allowed_user_groups_updated_successfully": "Sallitut käyttäjäryhmät päivitetty onnistuneesti",
"oidc_client_name": "OIDC-asiakas {name}",
"client_id": "Asiakas ID",
"client_secret": "Asiakkaan salaisuus",
"show_more_details": "Näytä lisätietoja",
"allowed_user_groups": "Sallitut käyttäjäryhmät",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Lisää käyttäjäryhmiä tähän asiakkaaseen rajoittaaksesi pääsyn näiden ryhmien käyttäjille. Jos käyttäjäryhmiä ei ole valittu, kaikki käyttäjät pääsevät käyttämään tätä asiakasta.",
"favicon": "Sivustokuvake",
"light_mode_logo": "Vaalean tilan logo",
"dark_mode_logo": "Tumman tilan logo",
"background_image": "Taustakuva",
"language": "Kieli",
"reset_profile_picture_question": "Palautetaanko profiilikuva?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Tämä poistaa ladatun kuvan ja palauttaa profiilikuva oletusasetuksiin. Haluatko jatkaa?",
"reset": "Palauta",
"reset_to_default": "Palauta oletukset",
"profile_picture_has_been_reset": "Profiilikuva on nollattu. Päivitys voi kestää muutaman minuutin.",
"select_the_language_you_want_to_use": "Valitse haluamasi kieli. Huomaa, että osa tekstistä saatetaan kääntää automaattisesti, jolloin käännös voi olla epätarkka.",
"contribute_to_translation": "Jos löydät ongelman, voit osallistua käännöstyöhön <link href='https://crowdin.com/project/pocket-id'>Crowdinissa</link>.",
"personal": "Henkilökohtainen",
"global": "Globaali",
"all_users": "Kaikki käyttäjät",
"all_events": "Kaikki tapahtumat",
"all_clients": "Kaikki asiakkaat",
"all_locations": "Kaikki sijainnit",
"global_audit_log": "Globaali tarkastusloki",
"see_all_account_activities_from_the_last_3_months": "Katso kaikkien käyttäjien toiminnot viimeisen 3 kuukauden ajalta.",
"token_sign_in": "Tunnuksella kirjautuminen",
"client_authorization": "Asiakkaan valtuutus",
"new_client_authorization": "Uuden asiakkaan valtuutus",
"device_code_authorization": "Laitteen koodivaltuutus",
"new_device_code_authorization": "Uuden laitteen koodivaltuutus",
"passkey_added": "Pääsyavain lisättiin",
"passkey_removed": "Pääsyavain poistettiin",
"disable_animations": "Poista animaatiot käytöstä",
"turn_off_ui_animations": "Poista animaatiot käytöstä koko käyttöliittymässä.",
"user_disabled": "Tili poistettu käytöstä",
"disabled_users_cannot_log_in_or_use_services": "Käytöstä poistetut käyttäjät eivät voi kirjautua sisään tai käyttää palveluita.",
"user_disabled_successfully": "Käyttäjä on poistettu käytöstä onnistuneesti.",
"user_enabled_successfully": "Käyttäjä on otettu käyttöön onnistuneesti.",
"status": "Tila",
"disable_firstname_lastname": "Poista käytöstä {firstName} {lastName}",
"are_you_sure_you_want_to_disable_this_user": "Haluatko varmasti poistaa tämän käyttäjän käytöstä? Hän ei voi kirjautua sisään tai käyttää mitään palveluita.",
"ldap_soft_delete_users": "Säilytä LDAP:sta käytöstä poistetut käyttäjät.",
"ldap_soft_delete_users_description": "Kun tämä toiminto on käytössä, LDAP:sta poistetut käyttäjät merkitään käytöstä poistetuiksi sen sijaan, että ne poistettaisiin järjestelmästä kokonaan.",
"login_code_email_success": "Pääsyavain poistettiin",
"send_email": "Lähetä sähköposti",
"show_code": "Näytä koodi",
"callback_url_description": "Asiakkaasi antamat URL-osoitteet. Lisätään automaattisesti, jos kenttä jätetään tyhjäksi. Villikortit (*) ovat tuettuja, mutta niitä on parempi välttää turvallisuussyistä.",
"logout_callback_url_description": "Asiakkaasi antamat URL-osoitteet kirjautumiseen. Villikortit (*) ovat tuettuja, mutta niitä on parempi välttää turvallisuuden vuoksi.",
"api_key_expiration": "API-avaimen voimassaolon päättyminen",
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Lähetä käyttäjälle sähköpostiviesti, kun hänen API-avaimensa on vanhentumassa.",
"authorize_device": "Valtuuta laite",
"the_device_has_been_authorized": "Laite on valtuutettu.",
"enter_code_displayed_in_previous_step": "Syötä edellisessä vaiheessa näkynyt koodi.",
"authorize": "Salli",
"federated_client_credentials": "Federoidut asiakastunnukset",
"federated_client_credentials_description": "Yhdistettyjen asiakastunnistetietojen avulla voit todentaa OIDC-asiakkaat kolmannen osapuolen myöntämillä JWT-tunnuksilla.",
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
"unrestricted": "Rajoittamaton",
"show_advanced_options": "Näytä lisäasetukset",
"hide_advanced_options": "Piilota lisäasetukset",
"oidc_data_preview": "OIDC-tietojen esikatselu",
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Esikatsele OIDC-tiedot, jotka lähetetään eri käyttäjille",
"id_token": "ID-tunnus",
"access_token": "Käyttöoikeustunnus",
"userinfo": "Käyttäjätieto",
"id_token_payload": "ID tunnuksen data",
"access_token_payload": "Pääsytunnuksen data",
"userinfo_endpoint_response": "Käyttäjätietojen päätepisteen vastaus",
"copy": "Kopioi",
"no_preview_data_available": "Esikatselutietoja ei saatavilla",
"copy_all": "Kopioi kaikki",
"preview": "Esikatsele",
"preview_for_user": "Esikatsele käyttäjänä {name}",
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Esikatsele OIDC-tiedot, jotka lähetetään tälle käyttäjälle",
"show": "Näytä",
"select_an_option": "Valitse vaihtoehto",
"select_user": "Valitse käyttäjä",
"error": "Virhe",
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Valitse korostusväri Pocket ID:n ulkoasun mukauttamiseksi.",
"accent_color": "Korostusväri",
"custom_accent_color": "Mukautettu korostusväri",
"custom_accent_color_description": "Syötä mukautettu väri käyttämällä CSS-väriformaatteja (esim. hex, rgb, hsl).",
"color_value": "Väriarvo",
"apply": "Käytä",
"signup_token": "Rekisteröitymistunnus",
"create_a_signup_token_to_allow_new_user_registration": "Luo rekisteröitymistunnus, jotta uudet käyttäjät voivat rekisteröityä.",
"usage_limit": "Käyttöraja",
"number_of_times_token_can_be_used": "Kuinka monta kertaa rekisteröitymistunnusta voidaan käyttää.",
"expires": "Vanhenee",
"signup": "Rekisteröidy",
"user_creation": "Käyttäjän luominen",
"configure_user_creation": "Hallitse käyttäjien luomisen asetuksia, mukaan lukien rekisteröitymistavat ja uusien käyttäjien oletusluvat.",
"user_creation_groups_description": "Määritä nämä ryhmät automaattisesti uusille käyttäjille rekisteröitymisen yhteydessä.",
"user_creation_claims_description": "Määritä nämä mukautetut vaatimukset automaattisesti uusille käyttäjille rekisteröitymisen yhteydessä.",
"user_creation_updated_successfully": "Käyttäjän luomisen asetukset päivitetty onnistuneesti.",
"signup_disabled_description": "Käyttäjien rekisteröityminen on kokonaan estetty. Vain järjestelmänvalvojat voivat luoda uusia käyttäjätilejä.",
"signup_requires_valid_token": "Tilisi luomiseen tarvitaan voimassa oleva rekisteröitymistunnus",
"validating_signup_token": "Kirjautumistunnuksen validointi",
"go_to_login": "Siirry kirjautumiseen",
"signup_to_appname": "Rekisteröidy palveluun {appName}",
"create_your_account_to_get_started": "Luo käyttäjä alottaaksesi.",
"initial_account_creation_description": "Luo tili aloittaaksesi. Voit asettaa pääsyavaimen myöhemmin.",
"setup_your_passkey": "Määritä pääsyavain",
"create_a_passkey_to_securely_access_your_account": "Luo pääsyavain, jolla voit kirjautua sisään tiliisi turvallisesti. Tämä tulee olemaan ensisijainen tapasi kirjautua sisään.",
"skip_for_now": "Ohita toistaiseksi",
"account_created": "Tili luotu",
"enable_user_signups": "Ota käyttäjien rekisteröityminen käyttöön",
"enable_user_signups_description": "Päätä, miten käyttäjät voivat rekisteröidä uusia tilejä Pocket ID:ssä.",
"user_signups_are_disabled": "Käyttäjien rekisteröityminen on tällä hetkellä pois käytöstä",
"create_signup_token": "Luo rekisteröitymistunnus",
"view_active_signup_tokens": "Näytä aktiiviset rekisteröitymistunnukset",
"manage_signup_tokens": "Hallitse rekisteröitymistunnuksia",
"view_and_manage_active_signup_tokens": "Tarkastele ja hallitse aktiivisia rekisteröitymistunnuksia.",
"signup_token_deleted_successfully": "Rekisteröitymistunnus poistettu onnistuneesti.",
"expired": "Vanhentunut",
"used_up": "Käytetty loppuun",
"active": "Aktiivinen",
"usage": "Käyttö",
"created": "Luotu",
"token": "Tunnus",
"loading": "Ladataan",
"delete_signup_token": "Poista rekisteröitymistunnus",
"are_you_sure_you_want_to_delete_this_signup_token": "Haluatko varmasti poistaa tämän rekisteröitymistunnuksen? Tätä toimintoa ei voi peruuttaa.",
"signup_with_token": "Rekisteröidy tunnuksella",
"signup_with_token_description": "Käyttäjät voivat rekisteröityä vain käyttämällä järjestelmänvalvojan luomaa voimassa olevaa rekisteröitymistunnusta.",
"signup_open": "Avoin rekisteröityminen",
"signup_open_description": "Kuka tahansa voi luoda uuden tilin ilman rajoituksia.",
"of": "/",
"skip_passkey_setup": "Ohita pääsyavaimen määritys",
"skip_passkey_setup_description": "On erittäin suositeltavaa asettaa pääsyavain, koska ilman sitä tilisi lukkiutuu heti, kun istunto vanhenee.",
"my_apps": "Omat sovellukset",
"no_apps_available": "Ei sovelluksia saatavilla",
"contact_your_administrator_for_app_access": "Ota yhteyttä järjestelmänvalvojaan saadaksesi pääsyn sovelluksiin.",
"launch": "Avaa",
"client_launch_url": "Asiakkaan käynnistys-URL",
"client_launch_url_description": "URL-osoite, joka avautuu, kun käyttäjä käynnistää sovelluksen Omat sovellukset -sivulta.",
"client_name_description": "Asiakkaan nimi, joka näkyy Pocket ID käyttöliittymässä.",
"revoke_access": "Peru käyttöoikeus",
"revoke_access_description": "Peruuta käyttöoikeus palveluun <b>{clientName}</b>. <b>{clientName}</b> palvelu ei voi enää käyttää tilisi tietoja.",
"revoke_access_successful": "Pääsy palveluun {clientName} on peruutettu onnistuneesti.",
"last_signed_in_ago": "Viimeksi kirjautunut {time} sitten",
"invalid_client_id": "Asiakastunnus voi sisältää vain kirjaimia, numeroita, alaviivoja ja väliviivoja",
"custom_client_id_description": "Aseta mukautettu asiakastunnus, jos sovelluksesi sitä vaatii. Muussa tapauksessa jätä kenttä tyhjäksi, jotta järjestelmä luo satunnaisen tunnuksen.",
"generated": "Luotu",
"administration": "Ylläpito",
"group_rdn_attribute_description": "Ryhmien erottavassa nimessä (DN) käytetty attribuutti.",
"display_name_attribute": "Näytönimien attribuutti",
"display_name": "Näyttönimi",
"configure_application_images": "Määritä sovelluksen kuvat",
"ui_config_disabled_info_title": "UI-asetukset poistettu käytöstä",
"ui_config_disabled_info_description": "Käyttöliittymän asetukset on poistettu käytöstä, koska sovelluksen asetuksia hallitaan ympäristömuuttujien avulla. Joitakin asetuksia ei ehkä voi muokata.",
"logo_from_url_description": "Liitä suora kuvan URL-osoite (svg, png, webp). Löydät kuvakkeita osoitteesta <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> tai <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
"invalid_url": "Virheellinen URL-osoite",
"require_user_email": "Vaadi sähköpostiosoite",
"require_user_email_description": "Vaatii käyttäjiltä sähköpostiosoitteen. Jos tämä asetus on pois käytöstä, käyttäjät, joilla ei ole sähköpostiosoitetta, eivät voi käyttää ominaisuuksia, jotka edellyttävät sähköpostiosoitetta.",
"view": "Näytä",
"toggle_columns": "Näytä sarakkeet",
"locale": "Kieli",
"ldap_id": "LDAP ID",
"reauthentication": "Uudelleentodentaminen",
"clear_filters": "Tyhjennä suodattimet",
"default_profile_picture": "Oletusprofiilikuva",
"light": "Vaalea",
"dark": "Tumma",
"system": "Järjestelmä"
}

View File

@@ -14,7 +14,7 @@
"profile_picture": "Photo de profil",
"profile_picture_is_managed_by_ldap_server": "La photo de profil est gérée par le serveur LDAP et ne peut pas être modifiée ici.",
"click_profile_picture_to_upload_custom": "Cliquez sur la photo de profil pour télécharger une photo depuis votre ordinateur.",
"image_should_be_in_format": "L'image doit être au format PNG, JPEG ou WEBP.",
"image_should_be_in_format": "L'image doit être au format PNG ou JPEG.",
"items_per_page": "Éléments par page",
"no_items_found": "Aucune donnée trouvée",
"select_items": "Sélectionner des éléments...",
@@ -331,10 +331,6 @@
"token_sign_in": "Connexion par jeton",
"client_authorization": "Autorisation client",
"new_client_authorization": "Nouvelle autorisation client",
"device_code_authorization": "Autorisation du code de l'appareil",
"new_device_code_authorization": "Autorisation du code du nouvel appareil",
"passkey_added": "Clé d'accès ajoutée",
"passkey_removed": "Clé d'accès supprimée",
"disable_animations": "Désactiver les animations",
"turn_off_ui_animations": "Désactiver les animations dans toute l'interface.",
"user_disabled": "Compte désactivé",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Immagine del profilo",
"profile_picture_is_managed_by_ldap_server": "L'immagine del profilo è gestita dal server LDAP e non può essere modificata qui.",
"click_profile_picture_to_upload_custom": "Clicca sull'immagine del profilo per caricarne una personalizzata dai tuoi file.",
"image_should_be_in_format": "L'immagine deve essere in formato PNG, JPEG o WEBP.",
"image_should_be_in_format": "L'immagine deve essere in formato PNG o JPEG.",
"items_per_page": "Elementi per pagina",
"no_items_found": "Nessun elemento trovato",
"select_items": "Scegli gli articoli...",
@@ -331,10 +331,6 @@
"token_sign_in": "Accesso con token",
"client_authorization": "Autorizzazione client",
"new_client_authorization": "Nuova autorizzazione client",
"device_code_authorization": "Autorizzazione codice dispositivo",
"new_device_code_authorization": "Autorizzazione codice nuovo dispositivo",
"passkey_added": "Passkey aggiunto",
"passkey_removed": "Passkey rimosso",
"disable_animations": "Disabilita animazioni",
"turn_off_ui_animations": "Disattiva tutte le animazioni della UI.",
"user_disabled": "Account disabilitato",

View File

@@ -14,7 +14,7 @@
"profile_picture": "プロフィール画像",
"profile_picture_is_managed_by_ldap_server": "プロフィール画像はLDAPサーバーによって管理されており、ここでは変更できません。",
"click_profile_picture_to_upload_custom": "プロフィール画像をクリックして、ファイルからカスタム画像をアップロードします。",
"image_should_be_in_format": "画像はPNG、JPEG、またはWEBP形式である必要があります。",
"image_should_be_in_format": "画像はPNGまたはJPEG形式である必要があります。",
"items_per_page": "ページあたりの表示件数",
"no_items_found": "項目が見つかりません",
"select_items": "項目を選択…",
@@ -331,10 +331,6 @@
"token_sign_in": "トークンサインイン",
"client_authorization": "Client Authorization",
"new_client_authorization": "New Client Authorization",
"device_code_authorization": "デバイスコード認証",
"new_device_code_authorization": "新規デバイス認証コード",
"passkey_added": "パスキー追加",
"passkey_removed": "パスキー削除済み",
"disable_animations": "アニメーションの無効化",
"turn_off_ui_animations": "Turn off animations throughout the UI.",
"user_disabled": "アカウントの無効化",

View File

@@ -14,7 +14,7 @@
"profile_picture": "프로필 사진",
"profile_picture_is_managed_by_ldap_server": "프로필 사진이 LDAP 서버에서 관리되어 여기에서 변경할 수 없습니다.",
"click_profile_picture_to_upload_custom": "프로필 사진을 클릭하여 파일에서 사용자 정의 사진을 업로드하세요.",
"image_should_be_in_format": "이미지는 PNG, JPEG 또는 WEBP 형식이어야 합니다.",
"image_should_be_in_format": "이미지는 PNG 또는 JPEG 형식이어야 합니다.",
"items_per_page": "페이지당 항목",
"no_items_found": "항목 없음",
"select_items": "항목을 선택하세요...",
@@ -331,10 +331,6 @@
"token_sign_in": "토큰 로그인",
"client_authorization": "클라이언트 승인",
"new_client_authorization": "새로운 클라이언트 승인",
"device_code_authorization": "기기 코드 인증",
"new_device_code_authorization": "신규 장치 코드 인증",
"passkey_added": "패스키 추가됨",
"passkey_removed": "패스키 제거됨",
"disable_animations": "애니메이션 비활성화",
"turn_off_ui_animations": "UI 전체의 애니메이션을 비활성화합니다.",
"user_disabled": "계정 비활성화",
@@ -467,7 +463,7 @@
"reauthentication": "재인증",
"clear_filters": "필터 지우기",
"default_profile_picture": "기본 프로필 사진",
"light": "라이트",
"dark": "다크",
"light": "",
"dark": "어둠",
"system": "시스템"
}

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profielfoto",
"profile_picture_is_managed_by_ldap_server": "De profielfoto wordt beheerd door de LDAP-server en kan hier niet worden gewijzigd.",
"click_profile_picture_to_upload_custom": "Klik op de profielfoto om een aangepaste foto uit je bestanden te uploaden.",
"image_should_be_in_format": "De afbeelding moet in PNG-, JPEG- of WEBP-formaat zijn.",
"image_should_be_in_format": "De afbeelding moet in PNG- of JPEG-formaat zijn.",
"items_per_page": "Aantal per pagina",
"no_items_found": "Geen items gevonden",
"select_items": "Kies items...",
@@ -331,10 +331,6 @@
"token_sign_in": "Inloggen met token",
"client_authorization": "Client autorisatie",
"new_client_authorization": "Nieuwe clientautorisatie",
"device_code_authorization": "Autorisatie van apparaatcode",
"new_device_code_authorization": "Nieuwe apparaatcode autoriseren",
"passkey_added": "Toegangscode toegevoegd",
"passkey_removed": "Toegangssleutel verwijderd",
"disable_animations": "Animaties uitzetten",
"turn_off_ui_animations": "Zet alle animaties in de gebruikersinterface uit.",
"user_disabled": "Account uitgeschakeld",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Zdjęcie profilowe",
"profile_picture_is_managed_by_ldap_server": "Zdjęcie profilowe jest zarządzane przez serwer LDAP i nie można go tutaj zmienić.",
"click_profile_picture_to_upload_custom": "Kliknij zdjęcie profilowe, aby przesłać własne z plików.",
"image_should_be_in_format": "Obraz powinien być w formacie PNG, JPEG lub WEBP.",
"image_should_be_in_format": "Obraz powinien być w formacie PNG lub JPEG.",
"items_per_page": "Elementów na stronę",
"no_items_found": "Nie znaleziono żadnych elementów",
"select_items": "Wybierz elementy...",
@@ -331,10 +331,6 @@
"token_sign_in": "Logowanie za pomocą tokena",
"client_authorization": "Autoryzacja klienta",
"new_client_authorization": "Nowa autoryzacja klienta",
"device_code_authorization": "Autoryzacja kodu urządzenia",
"new_device_code_authorization": "Autoryzacja nowego kodu urządzenia",
"passkey_added": "Dodano klucz dostępu",
"passkey_removed": "Klucz dostępu usunięty",
"disable_animations": "Wyłącz animacje",
"turn_off_ui_animations": "Wyłącz animacje w całym interfejsie użytkownika.",
"user_disabled": "Konto wyłączone",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Foto de Perfil",
"profile_picture_is_managed_by_ldap_server": "A foto de perfil é gerenciada pelo servidor LDAP e não pode ser alterada aqui.",
"click_profile_picture_to_upload_custom": "Clique na foto de perfil para enviar uma imagem personalizada dos seus arquivos.",
"image_should_be_in_format": "A imagem deve estar no formato PNG, JPEG ou WEBP.",
"image_should_be_in_format": "A imagem deve estar no formato PNG ou JPEG.",
"items_per_page": "Itens por página",
"no_items_found": "Nada foi encontrado",
"select_items": "Selecione os itens...",
@@ -331,10 +331,6 @@
"token_sign_in": "Entrar com token",
"client_authorization": "Autorização do cliente",
"new_client_authorization": "Autorização de novo cliente",
"device_code_authorization": "Autorização do código do dispositivo",
"new_device_code_authorization": "Autorização de novo código de dispositivo",
"passkey_added": "Chave de acesso adicionada",
"passkey_removed": "Chave de acesso removida",
"disable_animations": "Desativar animações",
"turn_off_ui_animations": "Desligue as animações em toda a interface do usuário.",
"user_disabled": "Conta desativada",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Изображение профиля",
"profile_picture_is_managed_by_ldap_server": "Изображение профиля управляется сервером LDAP и не может быть изменено здесь.",
"click_profile_picture_to_upload_custom": "Нажмите на изображение профиля, чтобы загрузить его из ваших файлов.",
"image_should_be_in_format": "Изображение должно быть в формате PNG, JPEG или WEBP.",
"image_should_be_in_format": "Изображение должно быть в формате PNG или JPEG.",
"items_per_page": "Элементов на странице",
"no_items_found": "Элементы не найдены",
"select_items": "Выбрать элементы...",
@@ -155,7 +155,7 @@
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Вы уверены, что хотите отозвать ключ API \"{apiKeyName}\"? Любые интеграции, использующие этот ключ, перестанут работать.",
"last_used": "Последнее использование",
"actions": "Действия",
"images_updated_successfully": "Изображения успешно обновлены. Это может занять пару минут для обновления.",
"images_updated_successfully": "Изображения обновились, но может занять пару минут.",
"general": "Общее",
"configure_smtp_to_send_emails": "Включить уведомления пользователей по электронной почте при обнаружении логина с нового устройства или локации.",
"ldap": "LDAP",
@@ -331,10 +331,6 @@
"token_sign_in": "Вход с помощью токена",
"client_authorization": "Авторизация клиента",
"new_client_authorization": "Авторизация нового клиента",
"device_code_authorization": "Авторизация через код устройства",
"new_device_code_authorization": "Новая авторизация через код устройства",
"passkey_added": "Пасскей добавлен",
"passkey_removed": "Пасскей удален",
"disable_animations": "Отключить анимации",
"turn_off_ui_animations": "Отключить все анимации в интерфейсе.",
"user_disabled": "Учетная запись отключена",
@@ -467,7 +463,7 @@
"reauthentication": "Повторная аутентификация",
"clear_filters": "Сбросить фильтры",
"default_profile_picture": "Изображение профиля по умолчанию",
"light": "Светлая",
"dark": "Темная",
"system": "Системная"
"light": "Свет",
"dark": "Темный",
"system": "Система"
}

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profilbild",
"profile_picture_is_managed_by_ldap_server": "Profilbilden hanteras av LDAP-servern och kan inte ändras här.",
"click_profile_picture_to_upload_custom": "Klicka på profilbilden för att ladda upp en anpassad bild från dina filer.",
"image_should_be_in_format": "Bilden ska vara i PNG-, JPEG- eller WEBP-format.",
"image_should_be_in_format": "Bilden ska vara i PNG- eller JPEG-format.",
"items_per_page": "Objekt per sida",
"no_items_found": "Inga objekt hittades",
"select_items": "Välj objekt...",
@@ -331,10 +331,6 @@
"token_sign_in": "Token-inloggning",
"client_authorization": "Godkännande av klient",
"new_client_authorization": "Ny klientauktorisation",
"device_code_authorization": "Enhetskodsgodkännande",
"new_device_code_authorization": "Auktorisering av ny enhetskod",
"passkey_added": "Passord tillagt",
"passkey_removed": "Passord borttaget",
"disable_animations": "Stäng av animationer",
"turn_off_ui_animations": "Stäng av animationer i hela användargränssnittet.",
"user_disabled": "Konto inaktiverat",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Profil resmi",
"profile_picture_is_managed_by_ldap_server": "Profil resmi LDAP sunucusu tarafından yönetilmektedir ve burada değiştirilemez.",
"click_profile_picture_to_upload_custom": "Özel bir resim yüklemek için profil resmine tıklayın.",
"image_should_be_in_format": "Resim PNG, JPEG veya WEBP formatında olmalıdır.",
"image_should_be_in_format": "Resim PNG veya JPEG formatında olmalıdır.",
"items_per_page": "Sayfa başına öğe sayısı",
"no_items_found": "Hiçbir öğe bulunamadı",
"select_items": "Öğeleri seçin...",
@@ -331,10 +331,6 @@
"token_sign_in": "Token ile Giriş",
"client_authorization": "İstemci Yetkilendirmesi",
"new_client_authorization": "Yeni İstemci Yetkilendirmesi",
"device_code_authorization": "Cihaz Kodu Yetkilendirme",
"new_device_code_authorization": "Yeni Cihaz Kodu Yetkilendirmesi",
"passkey_added": "Anahtar Eklendi",
"passkey_removed": "Geçiş Anahtarı Kaldırıldı",
"disable_animations": "Animasyonları Devre Dışı Bırak",
"turn_off_ui_animations": "Kullanıcı arayüzü animasyonlarını kapatın.",
"user_disabled": "Hesap Devre Dışı",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Фотографія профілю",
"profile_picture_is_managed_by_ldap_server": "Фотографія профілю управляється сервером LDAP і не може бути змінена тут.",
"click_profile_picture_to_upload_custom": "Натисніть на зображення профілю, щоб завантажити власне зображення.",
"image_should_be_in_format": "Зображення повинно бути у форматі PNG, JPEG або WEBP.",
"image_should_be_in_format": "Зображення повинно бути у форматі PNG або JPEG.",
"items_per_page": "Елементів на сторінці",
"no_items_found": "Нічого не знайдено",
"select_items": "Виберіть елементи...",
@@ -331,10 +331,6 @@
"token_sign_in": "Вхід за допомогою токена",
"client_authorization": "Авторизація клієнта",
"new_client_authorization": "Нова авторизація клієнта",
"device_code_authorization": "Авторизація коду пристрою",
"new_device_code_authorization": "Авторизація нового коду пристрою",
"passkey_added": "Додано пароль",
"passkey_removed": "Ключ доступу видалено",
"disable_animations": "Вимкнути анімацію",
"turn_off_ui_animations": "Вимкнути анімації у всьому інтерфейсі.",
"user_disabled": "Обліковий запис вимкнено",

View File

@@ -14,7 +14,7 @@
"profile_picture": "Ảnh đại diện",
"profile_picture_is_managed_by_ldap_server": "Hình đại diện được quản lý bởi máy chủ LDAP và không thể thay đổi tại đây.",
"click_profile_picture_to_upload_custom": "Nhấp vào hình ảnh hồ sơ để tải lên hình ảnh tùy chỉnh.",
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG, JPEG hoặc WEBP.",
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG hoặc JPEG.",
"items_per_page": "Số kết quả mỗi trang",
"no_items_found": "Không tìm thấy kết quả nào",
"select_items": "Chọn các mục...",
@@ -331,10 +331,6 @@
"token_sign_in": "Đăng nhập bằng token",
"client_authorization": "Xác thực client",
"new_client_authorization": "Xác thực client mới",
"device_code_authorization": "Xác thực mã thiết bị",
"new_device_code_authorization": "Xác thực mã thiết bị mới",
"passkey_added": "Thêm khóa truy cập",
"passkey_removed": "Khóa truy cập đã bị xóa",
"disable_animations": "Tắt hiệu ứng động",
"turn_off_ui_animations": "Tắt các hiệu ứng động trong giao diện người dùng.",
"user_disabled": "Tài khoản bị vô hiệu hóa",

View File

@@ -14,7 +14,7 @@
"profile_picture": "头像",
"profile_picture_is_managed_by_ldap_server": "头像由 LDAP 服务器管理,无法在此处更改。",
"click_profile_picture_to_upload_custom": "点击头像来从文件中上传您的自定义头像。",
"image_should_be_in_format": "图片应为 PNGJPEG 或 WEBP 格式。",
"image_should_be_in_format": "图片应为 PNGJPEG 格式。",
"items_per_page": "每页条数",
"no_items_found": "这里暂时空空如也",
"select_items": "选择项目……",
@@ -331,10 +331,6 @@
"token_sign_in": "Token 登录",
"client_authorization": "客户端授权",
"new_client_authorization": "首次客户端授权",
"device_code_authorization": "设备代码授权",
"new_device_code_authorization": "新设备代码授权",
"passkey_added": "密钥已添加",
"passkey_removed": "密钥已移除",
"disable_animations": "关闭动画",
"turn_off_ui_animations": "关闭界面中的所有动画效果。",
"user_disabled": "账户已禁用",

View File

@@ -14,7 +14,7 @@
"profile_picture": "個人資料圖片",
"profile_picture_is_managed_by_ldap_server": "這張個人資料圖片是由 LDAP 伺服器管理,無法在此變更。",
"click_profile_picture_to_upload_custom": "點擊個人資料圖片,從您的檔案中上傳自訂圖片。",
"image_should_be_in_format": "圖片應為 PNGJPEG 或 WEBP 格式。",
"image_should_be_in_format": "圖片應為 PNGJPEG 格式。",
"items_per_page": "每頁項目數",
"no_items_found": "找不到任何項目",
"select_items": "選擇項目...",
@@ -331,10 +331,6 @@
"token_sign_in": "Token 登入",
"client_authorization": "客戶端授權",
"new_client_authorization": "新客戶端授權",
"device_code_authorization": "裝置代碼授權",
"new_device_code_authorization": "新裝置代碼授權",
"passkey_added": "通行密鑰已添加",
"passkey_removed": "通行密鑰已移除",
"disable_animations": "停用動畫",
"turn_off_ui_animations": "關閉整個 UI 的動畫。",
"user_disabled": "帳號已停用",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "1.16.0",
"version": "1.15.0",
"private": true,
"type": "module",
"scripts": {
@@ -15,49 +15,49 @@
},
"dependencies": {
"@simplewebauthn/browser": "^13.2.2",
"@tailwindcss/vite": "^4.1.17",
"axios": "^1.13.2",
"@tailwindcss/vite": "^4.1.16",
"axios": "^1.12.2",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"jose": "^6.1.2",
"jose": "^5.10.0",
"qrcode": "^1.5.4",
"runed": "^0.37.0",
"sveltekit-superforms": "^2.28.1",
"tailwind-merge": "^3.4.0",
"zod": "^4.1.13"
"runed": "^0.31.1",
"sveltekit-superforms": "^2.28.0",
"tailwind-merge": "^3.3.1",
"zod": "^4.1.12"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.5.0",
"@inlang/paraglide-js": "^2.4.0",
"@inlang/plugin-m-function-matcher": "^2.1.0",
"@inlang/plugin-message-format": "^4.0.0",
"@internationalized/date": "^3.10.0",
"@lucide/svelte": "^0.555.0",
"@lucide/svelte": "^0.525.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.49.0",
"@sveltejs/kit": "^2.47.3",
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"@types/eslint": "^9.6.1",
"@types/node": "^24.10.1",
"@types/node": "^22.18.12",
"@types/qrcode": "^1.5.6",
"bits-ui": "^2.14.4",
"eslint": "^9.39.1",
"bits-ui": "^2.14.1",
"eslint": "^9.38.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-svelte": "^3.13.0",
"eslint-plugin-svelte": "^3.12.5",
"formsnap": "^2.0.1",
"globals": "^16.5.0",
"globals": "^16.4.0",
"mode-watcher": "^1.1.0",
"prettier": "^3.7.3",
"prettier": "^3.6.2",
"prettier-plugin-svelte": "^3.4.0",
"prettier-plugin-tailwindcss": "^0.7.1",
"rollup": "^4.53.3",
"svelte": "^5.45.2",
"svelte-check": "^4.3.4",
"svelte-sonner": "^1.0.6",
"tailwind-variants": "^3.2.2",
"tailwindcss": "^4.1.17",
"prettier-plugin-tailwindcss": "^0.6.14",
"rollup": "^4.52.5",
"svelte": "^5.41.3",
"svelte-check": "^4.3.3",
"svelte-sonner": "^1.0.5",
"tailwind-variants": "^1.0.0",
"tailwindcss": "^4.1.16",
"tslib": "^2.8.1",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.2.4"
"typescript-eslint": "^8.46.2",
"vite": "^7.1.12"
}
}

View File

@@ -7,7 +7,6 @@
"de",
"en",
"es",
"fi",
"fr",
"it",
"ko",

View File

@@ -1,8 +1,6 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@variant dark (&:where(.dark, .dark *));
/*
The default border color has changed to `currentcolor` in Tailwind CSS v4,
so we've added these compatibility styles to make sure everything still

View File

@@ -6,7 +6,6 @@
import type { AdvancedTableColumn } from '$lib/types/advanced-table.type';
import type { AuditLog, AuditLogFilter } from '$lib/types/audit-log.type';
import { translateAuditLogEvent } from '$lib/utils/audit-log-translator';
import { untrack } from 'svelte';
let {
isAdmin = false,
@@ -62,11 +61,7 @@
$effect(() => {
if (filters) {
filters.userID;
filters.event;
filters.location;
filters.clientName;
untrack(() => tableRef?.refresh());
tableRef?.refresh();
}
});

View File

@@ -4,7 +4,7 @@
import * as Command from '$lib/components/ui/command';
import * as Popover from '$lib/components/ui/popover';
import { cn } from '$lib/utils/style';
import { m } from '$lib/paraglide/messages';
import { m } from '$lib/paraglide/messages';
import { LoaderCircle, LucideCheck, LucideChevronDown } from '@lucide/svelte';
import type { FormEventHandler } from 'svelte/elements';

View File

@@ -39,9 +39,7 @@
{/if}
</div>
<div class="flex items-center justify-between gap-4">
{#if !isAuthPage}
<ModeSwitcher />
{/if}
<ModeSwitcher />
{#if $userStore?.id}
<HeaderAvatar />
{/if}

View File

@@ -1,11 +1,11 @@
<script lang="ts">
import MoonIcon from '@lucide/svelte/icons/moon';
import SunIcon from '@lucide/svelte/icons/sun';
import MoonIcon from '@lucide/svelte/icons/moon';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { m } from '$lib/paraglide/messages';
import { mode, resetMode, setMode } from 'mode-watcher';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { buttonVariants } from '$lib/components/ui/button/index.js';
import { m } from '$lib/paraglide/messages';
const isDark = $derived(mode.current === 'dark');
</script>

View File

@@ -36,10 +36,7 @@
async function createLoginCode() {
try {
code = await userService.createOneTimeAccessToken(
userId!,
availableExpirations[selectedExpiration]
);
code = await userService.createOneTimeAccessToken(userId!, availableExpirations[selectedExpiration]);
oneTimeLink = `${page.url.origin}/lc/${code}`;
} catch (e) {
axiosErrorToast(e);
@@ -48,10 +45,7 @@
async function sendLoginCodeEmail() {
try {
await userService.requestOneTimeAccessEmailAsAdmin(
userId!,
availableExpirations[selectedExpiration]
);
await userService.requestOneTimeAccessEmailAsAdmin(userId!, availableExpirations[selectedExpiration]);
toast.success(m.login_code_email_success());
onOpenChange(false);
} catch (e) {

View File

@@ -1,7 +1,7 @@
import Root from './skeleton.svelte';
import Root from "./skeleton.svelte";
export {
Root,
//
Root as Skeleton
Root as Skeleton,
};

View File

@@ -1,6 +1,6 @@
<script lang="ts">
import { cn, type WithElementRef, type WithoutChildren } from '$lib/utils/style.js';
import type { HTMLAttributes } from 'svelte/elements';
import { cn, type WithElementRef, type WithoutChildren } from "$lib/utils/style.js";
import type { HTMLAttributes } from "svelte/elements";
let {
ref = $bindable(null),
@@ -12,6 +12,6 @@
<div
bind:this={ref}
data-slot="skeleton"
class={cn('bg-accent animate-pulse rounded-md', className)}
class={cn("bg-accent animate-pulse rounded-md", className)}
{...restProps}
></div>

View File

@@ -1,13 +1,13 @@
import axios from 'axios';
abstract class APIService {
protected api = axios.create({ baseURL: '/api' });
protected api = axios.create({ baseURL: '/api' });
constructor() {
if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) {
this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL;
}
}
constructor() {
if (typeof process !== 'undefined' && process?.env?.DEVELOPMENT_BACKEND_URL) {
this.api.defaults.baseURL = process.env.DEVELOPMENT_BACKEND_URL;
}
}
}
export default APIService;

View File

@@ -4,35 +4,35 @@ import APIService from './api-service';
import userStore from '$lib/stores/user-store';
import type { AuthenticationResponseJSON, RegistrationResponseJSON } from '@simplewebauthn/browser';
class WebAuthnService extends APIService {
getRegistrationOptions = async () => (await this.api.get(`/webauthn/register/start`)).data;
getRegistrationOptions = async () => (await this.api.get(`/webauthn/register/start`)).data;
finishRegistration = async (body: RegistrationResponseJSON) =>
(await this.api.post(`/webauthn/register/finish`, body)).data as Passkey;
finishRegistration = async (body: RegistrationResponseJSON) =>
(await this.api.post(`/webauthn/register/finish`, body)).data as Passkey;
getLoginOptions = async () => (await this.api.get(`/webauthn/login/start`)).data;
getLoginOptions = async () => (await this.api.get(`/webauthn/login/start`)).data;
finishLogin = async (body: AuthenticationResponseJSON) =>
(await this.api.post(`/webauthn/login/finish`, body)).data as User;
finishLogin = async (body: AuthenticationResponseJSON) =>
(await this.api.post(`/webauthn/login/finish`, body)).data as User;
logout = async () => {
await this.api.post(`/webauthn/logout`);
userStore.clearUser();
};
logout = async () => {
await this.api.post(`/webauthn/logout`);
userStore.clearUser();
};
listCredentials = async () => (await this.api.get(`/webauthn/credentials`)).data as Passkey[];
listCredentials = async () => (await this.api.get(`/webauthn/credentials`)).data as Passkey[];
removeCredential = async (id: string) => {
await this.api.delete(`/webauthn/credentials/${id}`);
};
removeCredential = async (id: string) => {
await this.api.delete(`/webauthn/credentials/${id}`);
};
updateCredentialName = async (id: string, name: string) => {
await this.api.patch(`/webauthn/credentials/${id}`, { name });
};
updateCredentialName = async (id: string, name: string) => {
await this.api.patch(`/webauthn/credentials/${id}`, { name });
};
reauthenticate = async (body?: AuthenticationResponseJSON) => {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
};
reauthenticate = async (body?: AuthenticationResponseJSON) => {
const res = await this.api.post('/webauthn/reauthenticate', body);
return res.data.reauthenticationToken as string;
};
}
export default WebAuthnService;

View File

@@ -3,7 +3,7 @@ import type { Component, Snippet } from 'svelte';
export type AdvancedTableColumn<T extends Record<string, any>> = {
label: string;
column?: keyof T & string;
key?: string;
key?: string;
value?: (item: T) => string | number | boolean | undefined;
cell?: Snippet<[{ item: T }]>;
sortable?: boolean;
@@ -12,11 +12,9 @@ export type AdvancedTableColumn<T extends Record<string, any>> = {
value: string | boolean;
icon?: Component;
}[];
hidden?: boolean;
hidden?: boolean;
};
export type CreateAdvancedTableActions<T extends Record<string, any>> = (
item: T
) => AdvancedTableAction<T>[];
export type CreateAdvancedTableActions<T extends Record<string, any>> = (item: T) => AdvancedTableAction<T>[];
export type AdvancedTableAction<T> = {
label: string;

View File

@@ -12,7 +12,7 @@ export type AuditLog = {
};
export type AuditLogFilter = {
userID: string;
userId: string;
event: string;
location: string;
clientName: string;

View File

@@ -5,12 +5,8 @@ export const eventTypes: Record<string, string> = {
TOKEN_SIGN_IN: m.token_sign_in(),
CLIENT_AUTHORIZATION: m.client_authorization(),
NEW_CLIENT_AUTHORIZATION: m.new_client_authorization(),
ACCOUNT_CREATED: m.account_created(),
DEVICE_CODE_AUTHORIZATION: m.device_code_authorization(),
NEW_DEVICE_CODE_AUTHORIZATION: m.new_device_code_authorization(),
PASSKEY_ADDED: m.passkey_added(),
PASSKEY_REMOVED: m.passkey_removed()
};
ACCOUNT_CREATED: m.account_created()
}
/**
* Translates an audit log event type using paraglide messages.

View File

@@ -22,13 +22,9 @@ export const cachedApplicationLogo: CachableImage = {
export const cachedDefaultProfilePicture: CachableImage = {
getUrl: () =>
getCachedImageUrl(
new URL('/api/application-images/default-profile-picture', window.location.origin)
),
getCachedImageUrl(new URL('/api/application-images/default-profile-picture', window.location.origin)),
bustCache: () =>
bustImageCache(
new URL('/api/application-images/default-profile-picture', window.location.origin)
)
bustImageCache(new URL('/api/application-images/default-profile-picture', window.location.origin))
};
export const cachedBackgroundImage: CachableImage = {

View File

@@ -14,7 +14,6 @@
de: 'Deutsch',
en: 'English',
es: 'Español',
fi: 'Suomi',
fr: 'Français',
it: 'Italiano',
ja: '日本語',

View File

@@ -13,7 +13,7 @@
let auditLogListRef: AuditLogList;
let filters: AuditLogFilter = $state({
userID: '',
userId: '',
event: '',
location: '',
clientName: ''
@@ -59,7 +59,7 @@
label: username
}))
]}
bind:value={filters.userID}
bind:value={filters.userId}
/>
{/await}
</div>

4425
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,7 @@
packages:
- frontend
- tests
- email-templates
- 'frontend'
- 'tests'
- 'email-templates'
overrides:
cookie@<0.7.0: '>=0.7.0'
devalue: ^5.3.2
glob@>=11.0.0 <11.1.0: '>=11.1.0'
js-yaml@>=4.0.0 <4.1.1: '>=4.1.1'
valibot@>=0.31.0 <1.2.0: '>=1.2.0'
validator@<13.15.20: '>=13.15.20'
'devalue': '^5.3.2'

View File

@@ -7,10 +7,10 @@
"format": "prettier --write ."
},
"devDependencies": {
"@playwright/test": "^1.57.0",
"@types/node": "^24.10.1",
"dotenv": "^17.2.3",
"jose": "^6.1.2",
"prettier": "^3.7.0"
"@playwright/test": "^1.56.1",
"@types/node": "^22.18.12",
"dotenv": "^16.6.1",
"jose": "^6.1.0",
"prettier": "^3.6.2"
}
}

View File

@@ -20,10 +20,8 @@ services:
file: docker-compose.yml
service: pocket-id
environment:
- APP_ENV=test
- DB_PROVIDER=postgres
- DB_CONNECTION_STRING=postgres://postgres:postgres@postgres:5432/pocket-id
- FILE_BACKEND=${FILE_BACKEND}
depends_on:
postgres:
condition: service_healthy

View File

@@ -13,7 +13,7 @@ services:
retries: 10
create-bucket:
image: amazon/aws-cli:latest
image: amazon/aws-cli
environment:
AWS_ACCESS_KEY_ID: test
AWS_SECRET_ACCESS_KEY: test
@@ -28,14 +28,15 @@ services:
file: docker-compose.yml
service: pocket-id
environment:
- S3_BUCKET=pocket-id-test
- S3_REGION=us-east-1
- S3_ENDPOINT=http://localstack-s3:4566
- S3_ACCESS_KEY_ID=test
- S3_SECRET_ACCESS_KEY=test
- S3_FORCE_PATH_STYLE=true
- KEYS_STORAGE=database
- ENCRYPTION_KEY=test1234test1234test1234test1234
FILE_BACKEND: s3
S3_BUCKET: pocket-id-test
S3_REGION: us-east-1
S3_ENDPOINT: http://localstack-s3:4566
S3_ACCESS_KEY_ID: test
S3_SECRET_ACCESS_KEY: test
S3_FORCE_PATH_STYLE: true
KEYS_STORAGE: database
ENCRYPTION_KEY: test
depends_on:
create-bucket:
condition: service_completed_successfully

View File

@@ -14,7 +14,6 @@ services:
- '1411:1411'
environment:
- APP_ENV=test
- FILE_BACKEND=${FILE_BACKEND}
build:
args:
- BUILD_TAGS=e2etest

View File

@@ -66,7 +66,7 @@ test('Change Locale', async ({ page }) => {
// Check if the validation messages are translated because they are provided by Zod
await page.getByRole('textbox', { name: 'Voornaam' }).fill('');
await page.getByRole('button', { name: 'Opslaan' }).click();
await expect(page.getByText('Te klein: verwacht dat string te hebben >=1 tekens')).toBeVisible();
await expect(page.getByText('Te kort: verwacht dat string')).toBeVisible();
// Clear all cookies and sign in again to check if the language is still set to Dutch
await page.context().clearCookies();
@@ -76,7 +76,7 @@ test('Change Locale', async ({ page }) => {
await page.getByRole('textbox', { name: 'Voornaam' }).fill('');
await page.getByRole('button', { name: 'Opslaan' }).click();
await expect(page.getByText('Te klein: verwacht dat string te hebben >=1 tekens')).toBeVisible();
await expect(page.getByText('Te kort: verwacht dat string')).toBeVisible();
});
test('Add passkey to an account', async ({ page }) => {

View File

@@ -22,7 +22,7 @@ test.describe('API Key Management', () => {
await page.getByRole('button', { name: 'Select a date' }).click();
await page.getByLabel('Select year').click();
// Select the next year
await page.getByRole('option', { name: (currentDate.getFullYear() + 1).toString() }).click();
await page.getByText((currentDate.getFullYear() + 1).toString()).click();
// Select the first day of the month
await page
.getByRole('button', { name: /([A-Z][a-z]+), ([A-Z][a-z]+) 1, (\d{4})/ })
@@ -62,7 +62,7 @@ test.describe('API Key Management', () => {
await page.getByRole('menuitem', { name: 'Revoke' }).click();
await page.getByRole('button', { name: 'Revoke' }).click();
// Verify success message
await expect(page.locator('[data-type="success"]')).toHaveText('API key revoked successfully');