mirror of
https://github.com/pocket-id/pocket-id.git
synced 2025-12-09 14:52:57 +03:00
Compare commits
31 Commits
c3735c2ec9
...
breaking/v
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4bd20a90d | ||
|
|
91b0d74c43 | ||
|
|
3a1dd3168e | ||
|
|
25f67bd25a | ||
|
|
e3483a9c78 | ||
|
|
95d49256f6 | ||
|
|
900f8fe240 | ||
|
|
8cddcb88e8 | ||
|
|
a25d6ef56c | ||
|
|
1da694f8ad | ||
|
|
14c7471b52 | ||
|
|
5d6a7fdb58 | ||
|
|
a1cd3251cd | ||
|
|
4eeb06f29d | ||
|
|
b2c718d13d | ||
|
|
8d30346f64 | ||
|
|
714b7744f0 | ||
|
|
d98c0a391a | ||
|
|
4fe56a8d5c | ||
|
|
cfc9e464d9 | ||
|
|
e06538a101 | ||
|
|
3d46badb3c | ||
|
|
39c1f93756 | ||
|
|
e306d6eb58 | ||
|
|
f523f39483 | ||
|
|
4bde271b47 | ||
|
|
a3c968758a | ||
|
|
46793fe68a | ||
|
|
e22822890f | ||
|
|
2694d79add | ||
|
|
98cf1f66c3 |
14
.env.example
14
.env.example
@@ -1,6 +1,18 @@
|
||||
# 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
|
||||
21
.github/workflows/e2e-tests.yml
vendored
21
.github/workflows/e2e-tests.yml
vendored
@@ -59,9 +59,9 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- db: sqlite
|
||||
storage: fs
|
||||
storage: filesystem
|
||||
- db: postgres
|
||||
storage: fs
|
||||
storage: filesystem
|
||||
- db: sqlite
|
||||
storage: s3
|
||||
- db: sqlite
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
run: |
|
||||
DOCKER_COMPOSE_FILE=docker-compose.yml
|
||||
|
||||
export FILE_BACKEND="${{ matrix.storage }}"
|
||||
echo "FILE_BACKEND=${{ matrix.storage }}" > .env
|
||||
if [ "${{ matrix.db }}" = "postgres" ]; then
|
||||
DOCKER_COMPOSE_FILE=docker-compose-postgres.yml
|
||||
elif [ "${{ matrix.storage }}" = "s3" ]; then
|
||||
@@ -179,7 +179,20 @@ jobs:
|
||||
fi
|
||||
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" up -d
|
||||
docker compose -f "$DOCKER_COMPOSE_FILE" logs -f pocket-id &> /tmp/backend.log &
|
||||
|
||||
{
|
||||
LOG_FILE="/tmp/backend.log"
|
||||
while true; do
|
||||
CID=$(docker compose -f "$DOCKER_COMPOSE_FILE" ps -q pocket-id)
|
||||
if [ -n "$CID" ]; then
|
||||
echo "[$(date)] Attaching logs for $CID" >> "$LOG_FILE"
|
||||
docker logs -f --since=0 "$CID" >> "$LOG_FILE" 2>&1
|
||||
else
|
||||
echo "[$(date)] Container not yet running…" >> "$LOG_FILE"
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
} &
|
||||
|
||||
- name: Run Playwright tests
|
||||
working-directory: ./tests
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -15,6 +15,7 @@ node_modules
|
||||
/backend/bin
|
||||
pocket-id
|
||||
/tests/test-results/*.json
|
||||
.tmp/
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
4
.vscode/launch.json
vendored
4
.vscode/launch.json
vendored
@@ -5,12 +5,14 @@
|
||||
"name": "Backend",
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"envFile": "${workspaceFolder}/backend/cmd/.env",
|
||||
"envFile": "${workspaceFolder}/backend/.env",
|
||||
"env": {
|
||||
"APP_ENV": "development"
|
||||
},
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/backend/cmd/main.go",
|
||||
"buildFlags": "-tags=exclude_frontend",
|
||||
"cwd": "${workspaceFolder}/backend",
|
||||
},
|
||||
{
|
||||
"name": "Frontend",
|
||||
|
||||
41
CHANGELOG.md
41
CHANGELOG.md
@@ -1,3 +1,44 @@
|
||||
## 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
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
_ "time/tzdata"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/cmds"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
// @title Pocket ID API
|
||||
@@ -11,5 +14,9 @@ import (
|
||||
// @description.markdown
|
||||
|
||||
func main() {
|
||||
if err := common.ValidateEnvConfig(&common.EnvConfig); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "config error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cmds.Execute()
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@ module github.com/pocket-id/pocket-id/backend
|
||||
go 1.25
|
||||
|
||||
require (
|
||||
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/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/smithy-go v1.23.2
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
@@ -14,15 +14,14 @@ require (
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/fxamacker/cbor/v2 v2.9.0
|
||||
github.com/gin-contrib/slog v1.1.0
|
||||
github.com/gin-contrib/slog v1.2.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.17.0
|
||||
github.com/go-co-op/gocron/v2 v2.18.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/go-webauthn/webauthn v0.14.0
|
||||
github.com/go-webauthn/webauthn v0.15.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 +33,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.0.0
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.0
|
||||
github.com/spf13/cobra v1.10.1
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.13.0
|
||||
@@ -49,33 +48,34 @@ require (
|
||||
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.32.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/time v0.14.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/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/internal/ini v1.8.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.3 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 // indirect
|
||||
github.com/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/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.1 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.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
|
||||
@@ -83,20 +83,22 @@ 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.10 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.11 // 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-webauthn/x v0.1.25 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.26 // 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.6 // indirect
|
||||
github.com/google/go-tpm v0.9.7 // 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
|
||||
@@ -119,7 +121,6 @@ require (
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.32 // indirect
|
||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
@@ -128,17 +129,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.1 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.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/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/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.0 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // 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
|
||||
@@ -154,22 +155,20 @@ 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.8.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // 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/arch v0.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251125195548-87e1e737ad39 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.32.0 // indirect
|
||||
golang.org/x/oauth2 v0.33.0 // indirect
|
||||
golang.org/x/sys v0.38.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/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
|
||||
google.golang.org/protobuf v1.36.10 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.66.10 // indirect
|
||||
modernc.org/libc v1.67.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.39.1 // indirect
|
||||
modernc.org/sqlite v1.40.1 // indirect
|
||||
)
|
||||
|
||||
190
backend/go.sum
190
backend/go.sum
@@ -1,55 +1,57 @@
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Azure/go-ntlmssp v0.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/credentials v1.18.21 h1:56HGpsgnmD+2/KpG0ikvvR8+3v3COCwaF4r+oWwOeNA=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.18.21/go.mod h1:3YELwedmQbw7cXNaII2Wywd+YY58AmLPwX4LzARgmmA=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13 h1:T1brd5dR3/fzNFAQch/iBKeX07/ffu/cLu+q+RuzEWk=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.13/go.mod h1:Peg/GBAQ6JDt+RoBf4meB1wylmAipb7Kg2ZFakZTlwk=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13 h1:a+8/MLcWlIxo1lF9xaGt3J/u3yOZx+CdSveSNwjhD40=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.13/go.mod h1:oGnKwIYZ4XttyU2JWxFrwvhF6YKiK/9/wmE3v3Iu9K8=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13 h1:HBSI2kDkMdWz4ZM7FjwE7e/pWDEZ+nR95x8Ztet1ooY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.13/go.mod h1:YE94ZoDArI7awZqJzBAZ3PDD2zSfuP7w6P2knOzIn8M=
|
||||
github.com/aws/aws-sdk-go-v2/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.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.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.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.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/presigned-url v1.13.13 h1:kDqdFvMY4AtKoACfzIGD8A0+hbT41KTKF//gq7jITfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.13/go.mod h1:lmKuogqSU3HzQCwZ9ZtcqOc5XGMqtDK7OIc2+DxiUEg=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13 h1:zhBJXdhWIFZ1acfDYIhu4+LCzdUS2Vbcum7D01dXlHQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.13/go.mod h1:JaaOeCE368qn2Hzi3sEzY6FgAZVCIYcC2nwbro2QCh8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0 h1:ef6gIJR+xv/JQWwpa5FYirzoQctfSJm7tuDe3SZsUf8=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.90.0/go.mod h1:+wArOOrcHUevqdto9k1tKOF5++YTe9JEcPSc9Tx2ZSw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1 h1:0JPwLz1J+5lEOfy/g0SURC9cxhbQ1lIMHMa+AHZSzz0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.1/go.mod h1:fKvyjJcz63iL/ftA6RaM8sRCtN4r4zl4tjL3qw5ec7k=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5 h1:OWs0/j2UYR5LOGi88sD5/lhN6TDLG6SfA7CqsQO9zF0=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.5/go.mod h1:klO+ejMvYsB4QATfEOIXk8WAEwN4N0aBfJpvC+5SZBo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1 h1:mLlUgHn02ue8whiR4BmxxGJLR2gwU6s6ZzJ5wDamBUs=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.39.1/go.mod h1:E19xDjpzPZC7LS2knI9E6BaRFDK43Eul7vd6rSq2HWk=
|
||||
github.com/aws/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.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.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.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.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.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.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=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.1 h1:FBMC0zVz5XUmE4z9wF4Jey0An5FueFvOsTKKKtwIl7w=
|
||||
github.com/bytedance/sonic v1.14.1/go.mod h1:gi6uhQLMbTdeP0muCnrjHLeCUPyb70ujhnNlhOylAFc=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/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=
|
||||
@@ -95,10 +97,10 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
|
||||
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
|
||||
github.com/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.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=
|
||||
@@ -109,8 +111,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.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=
|
||||
@@ -126,10 +128,12 @@ 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-webauthn/webauthn v0.14.0 h1:ZLNPUgPcDlAeoxe+5umWG/tEeCoQIDr7gE2Zx2QnhL0=
|
||||
github.com/go-webauthn/webauthn v0.14.0/go.mod h1:QZzPFH3LJ48u5uEPAu+8/nWJImoLBWM7iAH/kSVSo6k=
|
||||
github.com/go-webauthn/x v0.1.25 h1:g/0noooIGcz/yCVqebcFgNnGIgBlJIccS+LYAa+0Z88=
|
||||
github.com/go-webauthn/x v0.1.25/go.mod h1:ieblaPY1/BVCV0oQTsA/VAo08/TWayQuJuo5Q+XxmTY=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
|
||||
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
|
||||
github.com/go-webauthn/x v0.1.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=
|
||||
@@ -152,8 +156,8 @@ github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvt
|
||||
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/google/go-tpm v0.9.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=
|
||||
@@ -168,6 +172,8 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l
|
||||
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
@@ -240,8 +246,6 @@ github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuE
|
||||
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
|
||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
|
||||
@@ -263,8 +267,8 @@ github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQ
|
||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
|
||||
github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsTUU=
|
||||
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.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=
|
||||
@@ -275,16 +279,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.55.0 h1:zccPQIqYCXDt5NmcEabyYvOnomjs8Tlwl7tISjJh9Mk=
|
||||
github.com/quic-go/quic-go v0.55.0/go.mod h1:DR51ilwU1uE164KuWXhinFcKWGlEjzys2l8zUl5Ss1U=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.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=
|
||||
@@ -302,18 +306,20 @@ 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=
|
||||
@@ -370,34 +376,34 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6
|
||||
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
|
||||
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
|
||||
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0 h1:fRAZQDcAFHySxpJ1TwlA1cJ4tvcrw7nXl9xWWC8N5CE=
|
||||
go.opentelemetry.io/proto/otlp v1.8.0/go.mod h1:tIeYOeNBU4cvmPqpaji1P+KbB4Oloai8wN4rWzRrFF0=
|
||||
go.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=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
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/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/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
|
||||
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
|
||||
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.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/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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -415,18 +421,18 @@ golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
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/rpc v0.0.0-20251022142026-3a174f9686a8 h1:M1rk8KBnUsBDg1oPGHNCxG4vc1f49epmTO7xscSajMk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251022142026-3a174f9686a8/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
|
||||
google.golang.org/grpc v1.76.0 h1:UnVkv1+uMLYXoIz6o7chp59WfQUYA2ex/BXQ9rHZu7A=
|
||||
google.golang.org/grpc v1.76.0/go.mod h1:Ju12QI8M6iQJtbcsV+awF5a4hfJMLi4X0JLo94ULZ6c=
|
||||
google.golang.org/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-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.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=
|
||||
@@ -437,20 +443,22 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.0 h1:0VlycGreVhK7RF/Bwt51Fk8v0xLiiiFdbGDPIZQ7mJY=
|
||||
gorm.io/gorm v1.31.0/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
|
||||
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
|
||||
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
|
||||
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
|
||||
modernc.org/libc v1.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=
|
||||
@@ -459,8 +467,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.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=
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
_ "github.com/golang-migrate/migrate/v4/source/file"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
@@ -15,6 +16,16 @@ import (
|
||||
)
|
||||
|
||||
func Bootstrap(ctx context.Context) error {
|
||||
var shutdownFns []utils.Service
|
||||
defer func() { //nolint:contextcheck
|
||||
// Invoke all shutdown functions on exit
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if err := utils.NewServiceRunner(shutdownFns...).Run(shutdownCtx); err != nil {
|
||||
slog.Error("Error during graceful shutdown", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Initialize the observability stack, including the logger, distributed tracing, and metrics
|
||||
shutdownFns, httpClient, err := initObservability(ctx, common.EnvConfig.MetricsEnabled, common.EnvConfig.TracingEnabled)
|
||||
if err != nil {
|
||||
@@ -22,34 +33,12 @@ 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,
|
||||
Root: common.EnvConfig.UploadPath,
|
||||
}
|
||||
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
|
||||
default:
|
||||
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
|
||||
}
|
||||
fileStorage, err := InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err)
|
||||
}
|
||||
@@ -65,6 +54,26 @@ func Bootstrap(ctx context.Context) error {
|
||||
return fmt.Errorf("failed to initialize services: %w", err)
|
||||
}
|
||||
|
||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Until(waitUntil)):
|
||||
}
|
||||
|
||||
shutdownFn := func(shutdownCtx context.Context) error {
|
||||
sErr := svc.appLockService.Release(shutdownCtx)
|
||||
if sErr != nil {
|
||||
return fmt.Errorf("failed to release application lock: %w", sErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
shutdownFns = append(shutdownFns, shutdownFn)
|
||||
|
||||
// Init the job scheduler
|
||||
scheduler, err := job.NewScheduler()
|
||||
if err != nil {
|
||||
@@ -76,28 +85,51 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
|
||||
// Init the router
|
||||
router := initRouter(db, svc)
|
||||
router, err := initRouter(db, svc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize router: %w", err)
|
||||
}
|
||||
|
||||
// Run all background services
|
||||
// This call blocks until the context is canceled
|
||||
err = utils.
|
||||
NewServiceRunner(router, scheduler.Run).
|
||||
Run(ctx)
|
||||
services := []utils.Service{svc.appLockService.RunRenewal, router}
|
||||
|
||||
if common.EnvConfig.AppEnv != "test" {
|
||||
services = append(services, scheduler.Run)
|
||||
}
|
||||
|
||||
err = utils.NewServiceRunner(services...).Run(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to run services: %w", err)
|
||||
}
|
||||
|
||||
// Invoke all shutdown functions
|
||||
// We give these a timeout of 5s
|
||||
// Note: we use a background context because the run context has been canceled already
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer shutdownCancel()
|
||||
err = utils.
|
||||
NewServiceRunner(shutdownFns...).
|
||||
Run(shutdownCtx) //nolint:contextcheck
|
||||
if err != nil {
|
||||
slog.Error("Error shutting down services", slog.Any("error", err))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func InitStorage(ctx context.Context, db *gorm.DB) (fileStorage storage.FileStorage, err error) {
|
||||
switch common.EnvConfig.FileBackend {
|
||||
case storage.TypeFileSystem:
|
||||
fileStorage, err = storage.NewFilesystemStorage(common.EnvConfig.UploadPath)
|
||||
case storage.TypeDatabase:
|
||||
fileStorage, err = storage.NewDatabaseStorage(db)
|
||||
case storage.TypeS3:
|
||||
s3Cfg := storage.S3Config{
|
||||
Bucket: common.EnvConfig.S3Bucket,
|
||||
Region: common.EnvConfig.S3Region,
|
||||
Endpoint: common.EnvConfig.S3Endpoint,
|
||||
AccessKeyID: common.EnvConfig.S3AccessKeyID,
|
||||
SecretAccessKey: common.EnvConfig.S3SecretAccessKey,
|
||||
ForcePathStyle: common.EnvConfig.S3ForcePathStyle,
|
||||
DisableDefaultIntegrityChecks: common.EnvConfig.S3DisableDefaultIntegrityChecks,
|
||||
Root: common.EnvConfig.UploadPath,
|
||||
}
|
||||
fileStorage, err = storage.NewS3Storage(ctx, s3Cfg)
|
||||
default:
|
||||
err = fmt.Errorf("unknown file storage backend: %s", common.EnvConfig.FileBackend)
|
||||
}
|
||||
if err != nil {
|
||||
return fileStorage, err
|
||||
}
|
||||
|
||||
return fileStorage, nil
|
||||
}
|
||||
|
||||
@@ -12,12 +12,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
_ "github.com/golang-migrate/migrate/v4/source/github"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
slogGorm "github.com/orandin/slog-gorm"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
@@ -26,11 +21,10 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
func NewDatabase() (db *gorm.DB, err error) {
|
||||
db, err = connectDatabase()
|
||||
db, err = ConnectDatabase()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
@@ -39,105 +33,15 @@ func NewDatabase() (db *gorm.DB, err error) {
|
||||
return nil, fmt.Errorf("failed to get sql.DB: %w", err)
|
||||
}
|
||||
|
||||
// Choose the correct driver for the database provider
|
||||
var driver database.Driver
|
||||
switch common.EnvConfig.DbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
|
||||
NoTxWrap: true,
|
||||
})
|
||||
case common.DbProviderPostgres:
|
||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||
default:
|
||||
// Should never happen at this point
|
||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := migrateDatabase(driver); err != nil {
|
||||
if err := utils.MigrateDatabase(sqlDb); err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func migrateDatabase(driver database.Driver) error {
|
||||
// Embedded migrations via iofs
|
||||
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||
source, err := iofs.New(resources.FS, path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
|
||||
requiredVersion, err := getRequiredMigrationVersion(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last migration version: %w", err)
|
||||
}
|
||||
|
||||
currentVersion, _, _ := m.Version()
|
||||
if currentVersion > requiredVersion {
|
||||
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
|
||||
if !common.EnvConfig.AllowDowngrade {
|
||||
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
|
||||
}
|
||||
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||
return migrateDatabaseFromGitHub(driver, requiredVersion)
|
||||
}
|
||||
|
||||
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply embedded migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
|
||||
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
|
||||
func getRequiredMigrationVersion(path string) (uint, error) {
|
||||
entries, err := resources.FS.ReadDir(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read migration directory: %w", err)
|
||||
}
|
||||
|
||||
var maxVersion uint
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
var version uint
|
||||
n, err := fmt.Sscanf(name, "%d_", &version)
|
||||
if err == nil && n == 1 {
|
||||
if version > maxVersion {
|
||||
maxVersion = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxVersion, nil
|
||||
}
|
||||
|
||||
func connectDatabase() (db *gorm.DB, err error) {
|
||||
func ConnectDatabase() (db *gorm.DB, err error) {
|
||||
var dialector gorm.Dialector
|
||||
|
||||
// Choose the correct database provider
|
||||
@@ -155,6 +59,12 @@ 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 {
|
||||
@@ -388,6 +298,27 @@ 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
|
||||
|
||||
@@ -2,6 +2,8 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -84,6 +86,29 @@ 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
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
func init() {
|
||||
registerTestControllers = []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services){
|
||||
func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.fileStorage)
|
||||
testService, err := service.NewTestService(db, svc.appConfigService, svc.jwtService, svc.ldapService, svc.appLockService, svc.fileStorage)
|
||||
if err != nil {
|
||||
slog.Error("Failed to initialize test service", slog.Any("error", err))
|
||||
os.Exit(1)
|
||||
|
||||
@@ -29,16 +29,7 @@ import (
|
||||
// This is used to register additional controllers for tests
|
||||
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
|
||||
|
||||
func initRouter(db *gorm.DB, svc *services) utils.Service {
|
||||
runner, err := initRouterInternal(db, svc)
|
||||
if err != nil {
|
||||
slog.Error("Failed to init router", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return runner
|
||||
}
|
||||
|
||||
func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
// Set the appropriate Gin mode based on the environment
|
||||
switch common.EnvConfig.AppEnv {
|
||||
case common.AppEnvProduction:
|
||||
@@ -63,6 +54,8 @@ 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())
|
||||
@@ -110,7 +103,17 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
srv := &http.Server{
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Handler: r,
|
||||
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)
|
||||
}),
|
||||
}
|
||||
|
||||
// Set up the listener
|
||||
|
||||
@@ -27,6 +27,7 @@ type services struct {
|
||||
apiKeyService *service.ApiKeyService
|
||||
versionService *service.VersionService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *service.AppLockService
|
||||
}
|
||||
|
||||
// Initializes all services
|
||||
@@ -40,6 +41,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
|
||||
|
||||
svc.fileStorage = fileStorage
|
||||
svc.appImagesService = service.NewAppImagesService(imageExtensions, fileStorage)
|
||||
svc.appLockService = service.NewAppLockService(db)
|
||||
|
||||
svc.emailService, err = service.NewEmailService(db, svc.appConfigService)
|
||||
if err != nil {
|
||||
|
||||
70
backend/internal/cmds/export.go
Normal file
70
backend/internal/cmds/export.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type exportFlags struct {
|
||||
Path string
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags exportFlags
|
||||
|
||||
exportCmd := &cobra.Command{
|
||||
Use: "export",
|
||||
Short: "Exports all data of Pocket ID into a ZIP file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runExport(cmd.Context(), flags)
|
||||
},
|
||||
}
|
||||
|
||||
exportCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to export the data to, or '-' to write to stdout")
|
||||
|
||||
rootCmd.AddCommand(exportCmd)
|
||||
}
|
||||
|
||||
// runExport orchestrates the export flow
|
||||
func runExport(ctx context.Context, flags exportFlags) error {
|
||||
db, err := bootstrap.NewDatabase()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
storage, err := bootstrap.InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
|
||||
exportService := service.NewExportService(db, storage)
|
||||
|
||||
var w io.Writer
|
||||
if flags.Path == "-" {
|
||||
w = os.Stdout
|
||||
} else {
|
||||
file, err := os.Create(flags.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create export file: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
w = file
|
||||
}
|
||||
|
||||
if err := exportService.ExportToZip(ctx, w); err != nil {
|
||||
return fmt.Errorf("failed to export data: %w", err)
|
||||
}
|
||||
|
||||
if flags.Path != "-" {
|
||||
fmt.Printf("Exported data to %s\n", flags.Path)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
191
backend/internal/cmds/import.go
Normal file
191
backend/internal/cmds/import.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/bootstrap"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type importFlags struct {
|
||||
Path string
|
||||
Yes bool
|
||||
ForcefullyAcquireLock bool
|
||||
}
|
||||
|
||||
func init() {
|
||||
var flags importFlags
|
||||
|
||||
importCmd := &cobra.Command{
|
||||
Use: "import",
|
||||
Short: "Imports all data of Pocket ID from a ZIP file",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return runImport(cmd.Context(), flags)
|
||||
},
|
||||
}
|
||||
|
||||
importCmd.Flags().StringVarP(&flags.Path, "path", "p", "pocket-id-export.zip", "Path to the ZIP file to import the data from, or '-' to read from stdin")
|
||||
importCmd.Flags().BoolVarP(&flags.Yes, "yes", "y", false, "Skip confirmation prompts")
|
||||
importCmd.Flags().BoolVarP(&flags.ForcefullyAcquireLock, "forcefully-acquire-lock", "", false, "Forcefully acquire the application lock by terminating the Pocket ID instance")
|
||||
|
||||
rootCmd.AddCommand(importCmd)
|
||||
}
|
||||
|
||||
// runImport handles the high-level orchestration of the import process
|
||||
func runImport(ctx context.Context, flags importFlags) error {
|
||||
if !flags.Yes {
|
||||
ok, err := askForConfirmation()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get confirmation: %w", err)
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted")
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
zipReader *zip.ReadCloser
|
||||
cleanup func()
|
||||
err error
|
||||
)
|
||||
|
||||
if flags.Path == "-" {
|
||||
zipReader, cleanup, err = readZipFromStdin()
|
||||
defer cleanup()
|
||||
} else {
|
||||
zipReader, err = zip.OpenReader(flags.Path)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open zip: %w", err)
|
||||
}
|
||||
defer zipReader.Close()
|
||||
|
||||
db, err := bootstrap.ConnectDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = acquireImportLock(ctx, db, flags.ForcefullyAcquireLock)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
storage, err := bootstrap.InitStorage(ctx, db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize storage: %w", err)
|
||||
}
|
||||
|
||||
importService := service.NewImportService(db, storage)
|
||||
err = importService.ImportFromZip(ctx, &zipReader.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to import data from zip: %w", err)
|
||||
}
|
||||
|
||||
fmt.Println("Import completed successfully.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
|
||||
// Check if the kv table exists, in case we are starting from an empty database
|
||||
exists, err := utils.DBTableExists(db, "kv")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check if kv table exists: %w", err)
|
||||
}
|
||||
if !exists {
|
||||
// This either means the database is empty, or the import is into an old version of PocketID that doesn't support locks
|
||||
// In either case, there's no lock to acquire
|
||||
fmt.Println("Could not acquire a lock because the 'kv' table does not exist. This is fine if you're importing into a new database, but make sure that there isn't an instance of Pocket ID currently running and using the same database.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Note that we do not call a deferred Release if the data was imported
|
||||
// This is because we are overriding the contents of the database, so the lock is automatically lost
|
||||
appLockService := service.NewAppLockService(db)
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
waitUntil, err := appLockService.Acquire(opCtx, force)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
//nolint:staticcheck
|
||||
return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
|
||||
}
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Until(waitUntil)):
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func askForConfirmation() (bool, error) {
|
||||
fmt.Println("WARNING: This feature is experimental and may not work correctly. Please create a backup before proceeding and report any issues you encounter.")
|
||||
fmt.Println()
|
||||
fmt.Println("WARNING: Import will erase all existing data at the following locations:")
|
||||
fmt.Printf("Database: %s\n", absolutePathOrOriginal(common.EnvConfig.DbConnectionString))
|
||||
fmt.Printf("Uploads Path: %s\n", absolutePathOrOriginal(common.EnvConfig.UploadPath))
|
||||
|
||||
ok, err := utils.PromptForConfirmation("Do you want to continue?")
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return ok, nil
|
||||
}
|
||||
|
||||
// absolutePathOrOriginal returns the absolute path of the given path, or the original if it fails
|
||||
func absolutePathOrOriginal(path string) string {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return abs
|
||||
}
|
||||
|
||||
func readZipFromStdin() (*zip.ReadCloser, func(), error) {
|
||||
tmpFile, err := os.CreateTemp("", "pocket-id-import-*.zip")
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to create temporary file: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
_ = os.Remove(tmpFile.Name())
|
||||
}
|
||||
|
||||
if _, err := io.Copy(tmpFile, os.Stdin); err != nil {
|
||||
tmpFile.Close()
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("failed to read data from stdin: %w", err)
|
||||
}
|
||||
|
||||
if err := tmpFile.Close(); err != nil {
|
||||
cleanup()
|
||||
return nil, nil, fmt.Errorf("failed to close temporary file: %w", err)
|
||||
}
|
||||
|
||||
r, err := zip.OpenReader(tmpFile.Name())
|
||||
if err != nil {
|
||||
cleanup()
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return r, cleanup, nil
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
@@ -78,7 +79,7 @@ func keyRotate(ctx context.Context, flags keyRotateFlags, db *gorm.DB, envConfig
|
||||
}
|
||||
if !ok {
|
||||
fmt.Println("Aborted")
|
||||
return nil
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package cmds
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -69,78 +67,14 @@ func TestKeyRotate(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Run("file storage", func(t *testing.T) {
|
||||
testKeyRotateWithFileStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||
})
|
||||
|
||||
t.Run("database storage", func(t *testing.T) {
|
||||
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||
})
|
||||
testKeyRotateWithDatabaseStorage(t, tt.flags, tt.wantErr, tt.errMsg)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyRotateWithFileStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||
// Create temporary directory for keys
|
||||
tempDir := t.TempDir()
|
||||
keysPath := filepath.Join(tempDir, "keys")
|
||||
err := os.MkdirAll(keysPath, 0755)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Set up file storage config
|
||||
envConfig := &common.EnvConfigSchema{
|
||||
KeysStorage: "file",
|
||||
KeysPath: keysPath,
|
||||
}
|
||||
|
||||
// Create test database
|
||||
db := testingutils.NewDatabaseForTest(t)
|
||||
|
||||
// Initialize app config service and create instance
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
instanceID := appConfigService.GetDbConfig().InstanceID.Value
|
||||
|
||||
// Check if key exists before rotation
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, envConfig, instanceID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Run the key rotation
|
||||
err = keyRotate(t.Context(), flags, db, envConfig)
|
||||
|
||||
if wantErr {
|
||||
require.Error(t, err)
|
||||
if errMsg != "" {
|
||||
require.ErrorContains(t, err, errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify key was created
|
||||
key, err := keyProvider.LoadKey()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, key)
|
||||
|
||||
// Verify the algorithm matches what we requested
|
||||
alg, _ := key.Algorithm()
|
||||
assert.NotEmpty(t, alg)
|
||||
if flags.Alg != "" {
|
||||
expectedAlg := flags.Alg
|
||||
if expectedAlg == "EdDSA" {
|
||||
// EdDSA keys should have the EdDSA algorithm
|
||||
assert.Equal(t, "EdDSA", alg.String())
|
||||
} else {
|
||||
assert.Equal(t, expectedAlg, alg.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func testKeyRotateWithDatabaseStorage(t *testing.T, flags keyRotateFlags, wantErr bool, errMsg string) {
|
||||
// Set up database storage config
|
||||
envConfig := &common.EnvConfigSchema{
|
||||
KeysStorage: "database",
|
||||
EncryptionKey: []byte("test-encryption-key-characters-long"),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import (
|
||||
)
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "pocket-id",
|
||||
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
|
||||
Long: "By default, this command starts the pocket-id server.",
|
||||
Use: "pocket-id",
|
||||
Short: "A simple and easy-to-use OIDC provider that allows users to authenticate with their passkeys to your services.",
|
||||
Long: "By default, this command starts the pocket-id server.",
|
||||
SilenceUsage: true,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
// Start the server
|
||||
err := bootstrap.Bootstrap(cmd.Context())
|
||||
|
||||
@@ -38,38 +38,40 @@ 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"`
|
||||
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 AppEnv `env:"APP_ENV" options:"toLower"`
|
||||
LogLevel string `env:"LOG_LEVEL" options:"toLower"`
|
||||
LogJSON bool `env:"LOG_JSON"`
|
||||
AppURL string `env:"APP_URL" options:"toLower,trimTrailingSlash"`
|
||||
DbProvider DbProvider
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
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"`
|
||||
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
MetricsEnabled bool `env:"METRICS_ENABLED"`
|
||||
TracingEnabled bool `env:"TRACING_ENABLED"`
|
||||
TrustProxy bool `env:"TRUST_PROXY"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
|
||||
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"`
|
||||
}
|
||||
|
||||
var EnvConfig = defaultConfig()
|
||||
@@ -87,8 +89,7 @@ func defaultConfig() EnvConfigSchema {
|
||||
AppEnv: AppEnvProduction,
|
||||
LogLevel: "info",
|
||||
DbProvider: "sqlite",
|
||||
FileBackend: "fs",
|
||||
KeysPath: "data/keys",
|
||||
FileBackend: "filesystem",
|
||||
AppURL: AppUrl,
|
||||
Port: "1411",
|
||||
Host: "0.0.0.0",
|
||||
@@ -116,32 +117,28 @@ func parseEnvConfig() error {
|
||||
return fmt.Errorf("error preparing env config: %w", err)
|
||||
}
|
||||
|
||||
err = validateEnvConfig(&EnvConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
}
|
||||
|
||||
// validateEnvConfig checks the EnvConfig for required fields and valid values
|
||||
func validateEnvConfig(config *EnvConfigSchema) error {
|
||||
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
|
||||
func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
|
||||
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||
}
|
||||
|
||||
switch config.DbProvider {
|
||||
case DbProviderSqlite:
|
||||
if config.DbConnectionString == "" {
|
||||
config.DbConnectionString = defaultSqliteConnString
|
||||
}
|
||||
case DbProviderPostgres:
|
||||
if config.DbConnectionString == "" {
|
||||
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
|
||||
}
|
||||
if len(config.EncryptionKey) < 16 {
|
||||
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
|
||||
}
|
||||
|
||||
switch {
|
||||
case config.DbConnectionString == "":
|
||||
config.DbProvider = DbProviderSqlite
|
||||
config.DbConnectionString = defaultSqliteConnString
|
||||
case strings.HasPrefix(config.DbConnectionString, "postgres://") || strings.HasPrefix(config.DbConnectionString, "postgresql://"):
|
||||
config.DbProvider = DbProviderPostgres
|
||||
default:
|
||||
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
|
||||
config.DbProvider = DbProviderSqlite
|
||||
}
|
||||
|
||||
parsedAppUrl, err := url.Parse(config.AppURL)
|
||||
@@ -165,33 +162,14 @@ func validateEnvConfig(config *EnvConfigSchema) error {
|
||||
}
|
||||
}
|
||||
|
||||
switch config.KeysStorage {
|
||||
// KeysStorage defaults to "file" if empty
|
||||
case "":
|
||||
config.KeysStorage = "file"
|
||||
case "database":
|
||||
if config.EncryptionKey == nil {
|
||||
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||
}
|
||||
case "file":
|
||||
// All good, these are valid values
|
||||
default:
|
||||
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
|
||||
}
|
||||
|
||||
switch config.FileBackend {
|
||||
case "s3":
|
||||
if config.KeysStorage == "file" {
|
||||
return errors.New("KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
|
||||
}
|
||||
case "database":
|
||||
// All good, these are valid values
|
||||
case "", "fs":
|
||||
case "s3", "database":
|
||||
case "", "filesystem":
|
||||
if config.UploadPath == "" {
|
||||
config.UploadPath = defaultFsUploadPath
|
||||
}
|
||||
default:
|
||||
return errors.New("invalid FILE_BACKEND value. Must be 'fs', 'database', or 's3'")
|
||||
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
|
||||
}
|
||||
|
||||
// Validate LOCAL_IPV6_RANGES
|
||||
|
||||
@@ -8,6 +8,20 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func parseAndValidateEnvConfig(t *testing.T) error {
|
||||
t.Helper()
|
||||
|
||||
if _, exists := os.LookupEnv("ENCRYPTION_KEY"); !exists {
|
||||
t.Setenv("ENCRYPTION_KEY", "0123456789abcdef")
|
||||
}
|
||||
|
||||
if err := parseEnvConfig(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return ValidateEnvConfig(&EnvConfig)
|
||||
}
|
||||
|
||||
func TestParseEnvConfig(t *testing.T) {
|
||||
// Store original config to restore later
|
||||
originalConfig := EnvConfig
|
||||
@@ -17,11 +31,10 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
|
||||
t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
|
||||
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
|
||||
@@ -29,147 +42,76 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
|
||||
t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "POSTGRES")
|
||||
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
|
||||
t.Setenv("APP_URL", "https://example.com")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, DbProviderPostgres, EnvConfig.DbProvider)
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid DB_PROVIDER", func(t *testing.T) {
|
||||
t.Run("should fail when ENCRYPTION_KEY is too short", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "invalid")
|
||||
t.Setenv("DB_CONNECTION_STRING", "test")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("ENCRYPTION_KEY", "short")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid DB_PROVIDER value")
|
||||
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be at least 16 bytes long")
|
||||
})
|
||||
|
||||
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, defaultSqliteConnString, EnvConfig.DbConnectionString)
|
||||
})
|
||||
|
||||
t.Run("should fail when Postgres DB_CONNECTION_STRING is missing", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "postgres")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "missing required env var 'DB_CONNECTION_STRING' for Postgres")
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid APP_URL", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "€://not-a-valid-url")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "APP_URL is not a valid URL")
|
||||
})
|
||||
|
||||
t.Run("should fail when APP_URL contains path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000/path")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "APP_URL must not contain a path")
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
|
||||
})
|
||||
|
||||
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
|
||||
})
|
||||
|
||||
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "file", EnvConfig.KeysStorage)
|
||||
})
|
||||
|
||||
t.Run("should fail when KEYS_STORAGE is 'database' but no encryption key", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("KEYS_STORAGE", "database")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
|
||||
})
|
||||
|
||||
t.Run("should accept valid KEYS_STORAGE values", func(t *testing.T) {
|
||||
validStorageTypes := []string{"file", "database"}
|
||||
|
||||
for _, storage := range validStorageTypes {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("KEYS_STORAGE", storage)
|
||||
if storage == "database" {
|
||||
t.Setenv("ENCRYPTION_KEY", "test-key")
|
||||
}
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, storage, EnvConfig.KeysStorage)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid KEYS_STORAGE value", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("KEYS_STORAGE", "invalid")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid value for KEYS_STORAGE")
|
||||
})
|
||||
|
||||
t.Run("should parse boolean environment variables correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("UI_CONFIG_DISABLED", "true")
|
||||
@@ -178,7 +120,7 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
t.Setenv("TRUST_PROXY", "true")
|
||||
t.Setenv("ANALYTICS_DISABLED", "false")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, EnvConfig.UiConfigDisabled)
|
||||
assert.True(t, EnvConfig.MetricsEnabled)
|
||||
@@ -189,19 +131,17 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
|
||||
t.Run("should parse string environment variables correctly", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "postgres")
|
||||
t.Setenv("DB_CONNECTION_STRING", "postgres://test")
|
||||
t.Setenv("APP_URL", "https://prod.example.com")
|
||||
t.Setenv("APP_ENV", "PRODUCTION")
|
||||
t.Setenv("UPLOAD_PATH", "/custom/uploads")
|
||||
t.Setenv("KEYS_PATH", "/custom/keys")
|
||||
t.Setenv("PORT", "8080")
|
||||
t.Setenv("HOST", "LOCALHOST")
|
||||
t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
|
||||
t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
|
||||
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AppEnvProduction, EnvConfig.AppEnv) // lowercased
|
||||
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
|
||||
@@ -211,38 +151,24 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
|
||||
t.Run("should normalize file backend and default upload path", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("FILE_BACKEND", "FS")
|
||||
t.Setenv("FILE_BACKEND", "FILESYSTEM")
|
||||
t.Setenv("UPLOAD_PATH", "")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "fs", EnvConfig.FileBackend)
|
||||
assert.Equal(t, "filesystem", EnvConfig.FileBackend)
|
||||
assert.Equal(t, defaultFsUploadPath, EnvConfig.UploadPath)
|
||||
})
|
||||
|
||||
t.Run("should fail when FILE_BACKEND is s3 but keys are stored on filesystem", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("FILE_BACKEND", "s3")
|
||||
|
||||
err := parseEnvConfig()
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "KEYS_STORAGE cannot be 'file' when FILE_BACKEND is 's3'")
|
||||
})
|
||||
|
||||
t.Run("should fail with invalid FILE_BACKEND value", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_PROVIDER", "sqlite")
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("FILE_BACKEND", "invalid")
|
||||
|
||||
err := parseEnvConfig()
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
|
||||
})
|
||||
|
||||
@@ -40,6 +40,11 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetLock(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if err := tc.TestService.ResetApplicationImages(c.Request.Context()); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -69,8 +74,6 @@ func (tc *TestController) resetAndSeedHandler(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
tc.TestService.SetJWTKeys()
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ type AppConfigUpdateDto struct {
|
||||
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName string `json:"ldapAttributeGroupName"`
|
||||
LdapAttributeAdminGroup string `json:"ldapAttributeAdminGroup"`
|
||||
LdapAdminGroupName string `json:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers string `json:"ldapSoftDeleteUsers"`
|
||||
EmailOneTimeAccessAsAdminEnabled string `json:"emailOneTimeAccessAsAdminEnabled" binding:"required"`
|
||||
EmailOneTimeAccessAsUnauthenticatedEnabled string `json:"emailOneTimeAccessAsUnauthenticatedEnabled" binding:"required"`
|
||||
|
||||
26
backend/internal/middleware/cache_control.go
Normal file
26
backend/internal/middleware/cache_control.go
Normal file
@@ -0,0 +1,26 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
45
backend/internal/middleware/cache_control_test.go
Normal file
45
backend/internal/middleware/cache_control_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
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"))
|
||||
}
|
||||
40
backend/internal/middleware/head_middleware.go
Normal file
40
backend/internal/middleware/head_middleware.go
Normal file
@@ -0,0 +1,40 @@
|
||||
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
|
||||
|
||||
}
|
||||
}
|
||||
@@ -77,7 +77,7 @@ type AppConfig struct {
|
||||
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
|
||||
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`
|
||||
LdapAttributeGroupName AppConfigVariable `key:"ldapAttributeGroupName"`
|
||||
LdapAttributeAdminGroup AppConfigVariable `key:"ldapAttributeAdminGroup"`
|
||||
LdapAdminGroupName AppConfigVariable `key:"ldapAdminGroupName"`
|
||||
LdapSoftDeleteUsers AppConfigVariable `key:"ldapSoftDeleteUsers"`
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ import (
|
||||
// DateTime custom type for time.Time to store date as unix timestamp for sqlite and as date for postgres
|
||||
type DateTime time.Time //nolint:recvcheck
|
||||
|
||||
func DateTimeFromString(str string) (DateTime, error) {
|
||||
t, err := time.Parse(time.RFC3339Nano, str)
|
||||
if err != nil {
|
||||
return DateTime{}, fmt.Errorf("failed to parse date string: %w", err)
|
||||
}
|
||||
|
||||
return DateTime(t), nil
|
||||
}
|
||||
|
||||
func (date *DateTime) Scan(value any) (err error) {
|
||||
switch v := value.(type) {
|
||||
case time.Time:
|
||||
|
||||
@@ -102,7 +102,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},
|
||||
LdapAttributeGroupName: model.AppConfigVariable{},
|
||||
LdapAttributeAdminGroup: model.AppConfigVariable{},
|
||||
LdapAdminGroupName: model.AppConfigVariable{},
|
||||
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||
}
|
||||
}
|
||||
|
||||
296
backend/internal/service/app_lock_service.go
Normal file
296
backend/internal/service/app_lock_service.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrLockUnavailable = errors.New("lock is already held by another process")
|
||||
ErrLockLost = errors.New("lock ownership lost")
|
||||
)
|
||||
|
||||
const (
|
||||
ttl = 30 * time.Second
|
||||
renewInterval = 20 * time.Second
|
||||
renewRetries = 3
|
||||
lockKey = "application_lock"
|
||||
)
|
||||
|
||||
type AppLockService struct {
|
||||
db *gorm.DB
|
||||
lockID string
|
||||
processID int64
|
||||
hostID string
|
||||
}
|
||||
|
||||
func NewAppLockService(db *gorm.DB) *AppLockService {
|
||||
host, err := os.Hostname()
|
||||
if err != nil || host == "" {
|
||||
host = "unknown-host"
|
||||
}
|
||||
|
||||
return &AppLockService{
|
||||
db: db,
|
||||
processID: int64(os.Getpid()),
|
||||
hostID: host,
|
||||
lockID: uuid.NewString(),
|
||||
}
|
||||
}
|
||||
|
||||
type lockValue struct {
|
||||
ProcessID int64 `json:"process_id"`
|
||||
HostID string `json:"host_id"`
|
||||
LockID string `json:"lock_id"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
}
|
||||
|
||||
func (lv *lockValue) Marshal() (string, error) {
|
||||
data, err := json.Marshal(lv)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
func (lv *lockValue) Unmarshal(raw string) error {
|
||||
if raw == "" {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal([]byte(raw), lv)
|
||||
}
|
||||
|
||||
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner.
|
||||
// If the lock is forcefully acquired, it blocks until the previous lock has expired.
|
||||
func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var prevLockRaw string
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.KV{}).
|
||||
Where("key = ?", lockKey).
|
||||
Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("value").
|
||||
Scan(&prevLockRaw).
|
||||
Error
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("query existing lock: %w", err)
|
||||
}
|
||||
|
||||
var prevLock lockValue
|
||||
if prevLockRaw != "" {
|
||||
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
nowUnix := now.Unix()
|
||||
|
||||
value := lockValue{
|
||||
ProcessID: s.processID,
|
||||
HostID: s.hostID,
|
||||
LockID: s.lockID,
|
||||
ExpiresAt: now.Add(ttl).Unix(),
|
||||
}
|
||||
raw, err := value.Marshal()
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("encode lock value: %w", err)
|
||||
}
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
INSERT INTO kv (key, value)
|
||||
VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value
|
||||
WHERE (json_extract(kv.value, '$.expires_at') < ?) OR ?
|
||||
`
|
||||
case "postgres":
|
||||
query = `
|
||||
INSERT INTO kv (key, value)
|
||||
VALUES ($1, $2)
|
||||
ON CONFLICT(key) DO UPDATE SET
|
||||
value = excluded.value
|
||||
WHERE ((kv.value::json->>'expires_at')::bigint < $3) OR ($4::boolean IS TRUE)
|
||||
`
|
||||
default:
|
||||
return time.Time{}, fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||
}
|
||||
|
||||
res := tx.WithContext(ctx).Exec(query, lockKey, raw, nowUnix, force)
|
||||
if res.Error != nil {
|
||||
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
|
||||
}
|
||||
|
||||
// If there is a lock that is not expired and force is false, no rows will be affected
|
||||
if res.RowsAffected == 0 {
|
||||
return time.Time{}, ErrLockUnavailable
|
||||
}
|
||||
|
||||
if force && prevLock.ExpiresAt > nowUnix && prevLock.LockID != s.lockID {
|
||||
waitUntil = time.Unix(prevLock.ExpiresAt, 0)
|
||||
}
|
||||
|
||||
attrs := []any{
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
}
|
||||
if wait := time.Until(waitUntil); wait > 0 {
|
||||
attrs = append(attrs, slog.Duration("wait_before_proceeding", wait))
|
||||
}
|
||||
slog.Info("Acquired application lock", attrs...)
|
||||
|
||||
return waitUntil, nil
|
||||
}
|
||||
|
||||
// RunRenewal keeps renewing the lock until the context is canceled.
|
||||
func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
||||
ticker := time.NewTicker(renewInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := s.renew(ctx); err != nil {
|
||||
return fmt.Errorf("renew lock: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Release releases the lock if it is held by this process.
|
||||
func (s *AppLockService) Release(ctx context.Context) error {
|
||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
DELETE FROM kv
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.lock_id') = ?
|
||||
`
|
||||
case "postgres":
|
||||
query = `
|
||||
DELETE FROM kv
|
||||
WHERE key = $1
|
||||
AND value::json->>'lock_id' = $2
|
||||
`
|
||||
default:
|
||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||
}
|
||||
|
||||
res := s.db.WithContext(opCtx).Exec(query, lockKey, s.lockID)
|
||||
if res.Error != nil {
|
||||
return fmt.Errorf("release lock failed: %w", res.Error)
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
slog.Warn("Application lock not held by this process, cannot release",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
)
|
||||
}
|
||||
|
||||
slog.Info("Released application lock",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
||||
func (s *AppLockService) renew(ctx context.Context) error {
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= renewRetries; attempt++ {
|
||||
now := time.Now()
|
||||
nowUnix := now.Unix()
|
||||
expiresAt := now.Add(ttl).Unix()
|
||||
|
||||
value := lockValue{
|
||||
LockID: s.lockID,
|
||||
ProcessID: s.processID,
|
||||
HostID: s.hostID,
|
||||
ExpiresAt: expiresAt,
|
||||
}
|
||||
raw, err := value.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode lock value: %w", err)
|
||||
}
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
UPDATE kv
|
||||
SET value = ?
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.lock_id') = ?
|
||||
AND json_extract(value, '$.expires_at') > ?
|
||||
`
|
||||
case "postgres":
|
||||
query = `
|
||||
UPDATE kv
|
||||
SET value = $1
|
||||
WHERE key = $2
|
||||
AND value::json->>'lock_id' = $3
|
||||
AND ((value::json->>'expires_at')::bigint > $4)
|
||||
`
|
||||
default:
|
||||
return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
|
||||
}
|
||||
|
||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
|
||||
cancel()
|
||||
|
||||
switch {
|
||||
case res.Error != nil:
|
||||
lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
|
||||
case res.RowsAffected == 0:
|
||||
// Must be after checking res.Error
|
||||
return ErrLockLost
|
||||
default:
|
||||
slog.Debug("Renewed application lock",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Wait before next attempt or cancel if context is done
|
||||
if attempt < renewRetries {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(1 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
189
backend/internal/service/app_lock_service_test.go
Normal file
189
backend/internal/service/app_lock_service_test.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func newTestAppLockService(t *testing.T, db *gorm.DB) *AppLockService {
|
||||
t.Helper()
|
||||
|
||||
return &AppLockService{
|
||||
db: db,
|
||||
processID: 1,
|
||||
hostID: "test-host",
|
||||
lockID: "a13c7673-c7ae-49f1-9112-2cd2d0d4b0c1",
|
||||
}
|
||||
}
|
||||
|
||||
func insertLock(t *testing.T, db *gorm.DB, value lockValue) {
|
||||
t.Helper()
|
||||
|
||||
raw, err := value.Marshal()
|
||||
require.NoError(t, err)
|
||||
|
||||
err = db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func readLockValue(t *testing.T, db *gorm.DB) lockValue {
|
||||
t.Helper()
|
||||
|
||||
var row model.KV
|
||||
err := db.Take(&row, "key = ?", lockKey).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
require.NotNil(t, row.Value)
|
||||
|
||||
var value lockValue
|
||||
err = value.Unmarshal(*row.Value)
|
||||
require.NoError(t, err)
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
func TestAppLockServiceAcquire(t *testing.T) {
|
||||
t.Run("creates new lock when none exists", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, service.processID, stored.ProcessID)
|
||||
require.Equal(t, service.hostID, stored.HostID)
|
||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||
})
|
||||
|
||||
t.Run("returns ErrLockUnavailable when lock held by another process", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
existing := lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
insertLock(t, db, existing)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.ErrorIs(t, err, ErrLockUnavailable)
|
||||
|
||||
current := readLockValue(t, db)
|
||||
require.Equal(t, existing, current)
|
||||
})
|
||||
|
||||
t.Run("force acquisition steals lock", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
insertLock(t, db, lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
ExpiresAt: time.Now().Unix(),
|
||||
})
|
||||
|
||||
_, err := service.Acquire(context.Background(), true)
|
||||
require.NoError(t, err)
|
||||
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, service.processID, stored.ProcessID)
|
||||
require.Equal(t, service.hostID, stored.HostID)
|
||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLockServiceRelease(t *testing.T) {
|
||||
t.Run("removes owned lock", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = service.Release(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
var row model.KV
|
||||
err = db.Take(&row, "key = ?", lockKey).Error
|
||||
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
|
||||
})
|
||||
|
||||
t.Run("ignores lock held by another owner", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
existing := lockValue{
|
||||
ProcessID: 2,
|
||||
HostID: "other-host",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
insertLock(t, db, existing)
|
||||
|
||||
err := service.Release(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, existing, stored)
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLockServiceRenew(t *testing.T) {
|
||||
t.Run("extends expiration when lock is still owned", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
before := readLockValue(t, db)
|
||||
|
||||
err = service.renew(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
after := readLockValue(t, db)
|
||||
require.Equal(t, service.processID, after.ProcessID)
|
||||
require.Equal(t, service.hostID, after.HostID)
|
||||
require.GreaterOrEqual(t, after.ExpiresAt, before.ExpiresAt)
|
||||
})
|
||||
|
||||
t.Run("returns ErrLockLost when lock is missing", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
err := service.renew(context.Background())
|
||||
require.ErrorIs(t, err, ErrLockLost)
|
||||
})
|
||||
|
||||
t.Run("returns ErrLockLost when ownership changed", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Simulate a different process taking the lock.
|
||||
newOwner := lockValue{
|
||||
ProcessID: 9,
|
||||
HostID: "stolen-host",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
raw, marshalErr := newOwner.Marshal()
|
||||
require.NoError(t, marshalErr)
|
||||
updateErr := db.Model(&model.KV{}).
|
||||
Where("key = ?", lockKey).
|
||||
Update("value", raw).Error
|
||||
require.NoError(t, updateErr)
|
||||
|
||||
err = service.renew(context.Background())
|
||||
require.ErrorIs(t, err, ErrLockLost)
|
||||
})
|
||||
}
|
||||
@@ -7,14 +7,12 @@ import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"time"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"github.com/go-webauthn/webauthn/protocol"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
@@ -36,15 +34,17 @@ type TestService struct {
|
||||
appConfigService *AppConfigService
|
||||
ldapService *LdapService
|
||||
fileStorage storage.FileStorage
|
||||
appLockService *AppLockService
|
||||
externalIdPKey jwk.Key
|
||||
}
|
||||
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
func NewTestService(db *gorm.DB, appConfigService *AppConfigService, jwtService *JwtService, ldapService *LdapService, appLockService *AppLockService, fileStorage storage.FileStorage) (*TestService, error) {
|
||||
s := &TestService{
|
||||
db: db,
|
||||
appConfigService: appConfigService,
|
||||
jwtService: jwtService,
|
||||
ldapService: ldapService,
|
||||
appLockService: appLockService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
err := s.initExternalIdP()
|
||||
@@ -288,8 +288,8 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
// openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 | \
|
||||
// openssl pkcs8 -topk8 -nocrypt | tee >(openssl pkey -pubout)
|
||||
|
||||
publicKeyPasskey1, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEwcOo5KV169KR67QEHrcYkeXE3CCxv2BgwnSq4VYTQxyLtdmKxegexa8JdwFKhKXa2BMI9xaN15BoL6wSCRFJhg==")
|
||||
publicKeyPasskey2, _ := s.getCborPublicKey("MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbL5nPhZRAdJ3hEaqrHMSnJBhfMqtQGKwDYpaLIQFAKLhw==")
|
||||
publicKeyPasskey1, _ := base64.StdEncoding.DecodeString("pQMmIAEhWCDBw6jkpXXr0pHrtAQetxiR5cTcILG/YGDCdKrhVhNDHCJYIIu12YrF6B7Frwl3AUqEpdrYEwj3Fo3XkGgvrBIJEUmGAQI=")
|
||||
publicKeyPasskey2, _ := base64.StdEncoding.DecodeString("pSJYIPmc+FlEB0neERqqscxKckGF8yq1AYrANiloshAUAouHAQIDJiABIVggj4qA0PrZzg8Co1C27nyUbzrp8Ewjr7eOlGI2LfrzmbI=")
|
||||
webauthnCredentials := []model.WebauthnCredential{
|
||||
{
|
||||
Name: "Passkey 1",
|
||||
@@ -318,6 +318,10 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Challenge: "challenge",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserVerification: "preferred",
|
||||
CredentialParams: model.CredentialParameters{
|
||||
{Type: "public-key", Algorithm: -7},
|
||||
{Type: "public-key", Algorithm: -257},
|
||||
},
|
||||
}
|
||||
if err := tx.Create(&webauthnSession).Error; err != nil {
|
||||
return err
|
||||
@@ -327,9 +331,10 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Base: model.Base{
|
||||
ID: "5f1fa856-c164-4295-961e-175a0d22d725",
|
||||
},
|
||||
Name: "Test API Key",
|
||||
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
|
||||
UserID: users[0].ID,
|
||||
Name: "Test API Key",
|
||||
Key: "6c34966f57ef2bb7857649aff0e7ab3ad67af93c846342ced3f5a07be8706c20",
|
||||
UserID: users[0].ID,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(30 * 24 * time.Hour)),
|
||||
}
|
||||
if err := tx.Create(&apiKey).Error; err != nil {
|
||||
return err
|
||||
@@ -379,6 +384,20 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
}
|
||||
|
||||
keyValues := []model.KV{
|
||||
{
|
||||
Key: jwkutils.PrivateKeyDBKey,
|
||||
// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
|
||||
Value: utils.Ptr("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
|
||||
},
|
||||
}
|
||||
|
||||
for _, kv := range keyValues {
|
||||
if err := tx.Create(&kv).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -464,47 +483,29 @@ func (s *TestService) ResetAppConfig(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Manually set instance ID
|
||||
err = s.appConfigService.UpdateAppConfigValues(ctx, "instanceId", "test-instance-id")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the app config from the database after resetting the values
|
||||
return s.appConfigService.LoadDbConfig(ctx)
|
||||
err = s.appConfigService.LoadDbConfig(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Reload the JWK
|
||||
if err := s.jwtService.LoadOrGenerateKey(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *TestService) SetJWTKeys() {
|
||||
const privateKeyString = `{"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}`
|
||||
|
||||
privateKey, _ := jwk.ParseKey([]byte(privateKeyString))
|
||||
_ = s.jwtService.SetKey(privateKey)
|
||||
}
|
||||
|
||||
// getCborPublicKey decodes a Base64 encoded public key and returns the CBOR encoded COSE key
|
||||
func (s *TestService) getCborPublicKey(base64PublicKey string) ([]byte, error) {
|
||||
decodedKey, err := base64.StdEncoding.DecodeString(base64PublicKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decode base64 key: %w", err)
|
||||
}
|
||||
pubKey, err := x509.ParsePKIXPublicKey(decodedKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse public key: %w", err)
|
||||
}
|
||||
|
||||
ecdsaPubKey, ok := pubKey.(*ecdsa.PublicKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("not an ECDSA public key")
|
||||
}
|
||||
|
||||
coseKey := map[int]interface{}{
|
||||
1: 2, // Key type: EC2
|
||||
3: -7, // Algorithm: ECDSA with SHA-256
|
||||
-1: 1, // Curve: P-256
|
||||
-2: ecdsaPubKey.X.Bytes(), // X coordinate
|
||||
-3: ecdsaPubKey.Y.Bytes(), // Y coordinate
|
||||
}
|
||||
|
||||
cborPublicKey, err := cbor.Marshal(coseKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal COSE key: %w", err)
|
||||
}
|
||||
|
||||
return cborPublicKey, nil
|
||||
func (s *TestService) ResetLock(ctx context.Context) error {
|
||||
_, err := s.appLockService.Acquire(ctx, true)
|
||||
return err
|
||||
}
|
||||
|
||||
// SyncLdap triggers an LDAP synchronization
|
||||
@@ -531,7 +532,7 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error {
|
||||
"ldapAttributeGroupUniqueIdentifier": "uuid",
|
||||
"ldapAttributeGroupName": "uid",
|
||||
"ldapAttributeGroupMember": "member",
|
||||
"ldapAttributeAdminGroup": "admin_group",
|
||||
"ldapAdminGroupName": "admin_group",
|
||||
"ldapSoftDeleteUsers": "true",
|
||||
"ldapEnabled": "true",
|
||||
}
|
||||
|
||||
217
backend/internal/service/export_service.go
Normal file
217
backend/internal/service/export_service.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
// ExportService handles exporting Pocket ID data into a ZIP archive.
|
||||
type ExportService struct {
|
||||
db *gorm.DB
|
||||
storage storage.FileStorage
|
||||
}
|
||||
|
||||
func NewExportService(db *gorm.DB, storage storage.FileStorage) *ExportService {
|
||||
return &ExportService{
|
||||
db: db,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
// ExportToZip performs the full export process and writes the ZIP data to the given writer.
|
||||
func (s *ExportService) ExportToZip(ctx context.Context, w io.Writer) error {
|
||||
dbData, err := s.extractDatabase()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return s.writeExportZipStream(ctx, w, dbData)
|
||||
}
|
||||
|
||||
// extractDatabase reads all tables into a DatabaseExport struct
|
||||
func (s *ExportService) extractDatabase() (DatabaseExport, error) {
|
||||
schema, err := utils.LoadDBSchemaTypes(s.db)
|
||||
if err != nil {
|
||||
return DatabaseExport{}, fmt.Errorf("failed to load schema types: %w", err)
|
||||
}
|
||||
|
||||
version, err := s.schemaVersion()
|
||||
if err != nil {
|
||||
return DatabaseExport{}, err
|
||||
}
|
||||
|
||||
out := DatabaseExport{
|
||||
Provider: s.db.Name(),
|
||||
Version: version,
|
||||
Tables: map[string][]map[string]any{},
|
||||
// These tables need to be inserted in a specific order because of foreign key constraints
|
||||
// Not all tables are listed here, because not all tables are order-dependent
|
||||
TableOrder: []string{"users", "user_groups", "oidc_clients"},
|
||||
}
|
||||
|
||||
for table := range schema {
|
||||
if table == "storage" || table == "schema_migrations" {
|
||||
continue
|
||||
}
|
||||
err = s.dumpTable(table, schema[table], &out)
|
||||
if err != nil {
|
||||
return DatabaseExport{}, err
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *ExportService) schemaVersion() (uint, error) {
|
||||
var version uint
|
||||
if err := s.db.Raw("SELECT version FROM schema_migrations").Row().Scan(&version); err != nil {
|
||||
return 0, fmt.Errorf("failed to query schema version: %w", err)
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// dumpTable selects all rows from a table and appends them to out.Tables
|
||||
func (s *ExportService) dumpTable(table string, types utils.DBSchemaTableTypes, out *DatabaseExport) error {
|
||||
rows, err := s.db.Raw("SELECT * FROM " + table).Rows()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read table %s: %w", table, err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
cols, _ := rows.Columns()
|
||||
if len(cols) != len(types) {
|
||||
// Should never happen...
|
||||
return fmt.Errorf("mismatched columns in table (%d) and schema (%d)", len(cols), len(types))
|
||||
}
|
||||
|
||||
for rows.Next() {
|
||||
vals := s.getScanValuesForTable(cols, types)
|
||||
err = rows.Scan(vals...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan row in table %s: %w", table, err)
|
||||
}
|
||||
|
||||
rowMap := make(map[string]any, len(cols))
|
||||
for i, col := range cols {
|
||||
rowMap[col] = vals[i]
|
||||
}
|
||||
|
||||
// Skip the app lock row in the kv table
|
||||
if table == "kv" {
|
||||
if keyPtr, ok := rowMap["key"].(*string); ok && keyPtr != nil && *keyPtr == lockKey {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
out.Tables[table] = append(out.Tables[table], rowMap)
|
||||
}
|
||||
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
func (s *ExportService) getScanValuesForTable(cols []string, types utils.DBSchemaTableTypes) []any {
|
||||
res := make([]any, len(cols))
|
||||
for i, col := range cols {
|
||||
// Store a pointer
|
||||
// Note: don't create a helper function for this switch, because it would return type "any" and mess everything up
|
||||
// If the column is nullable, we need a pointer to a pointer!
|
||||
switch types[col].Name {
|
||||
case "boolean", "bool":
|
||||
var x bool
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
}
|
||||
case "blob", "bytea", "jsonb":
|
||||
// Treat jsonb columns as binary too
|
||||
var x []byte
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
}
|
||||
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
|
||||
var x datatype.DateTime
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
}
|
||||
case "integer", "int", "bigint":
|
||||
var x int64
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
}
|
||||
default:
|
||||
// Treat everything else as a string (including the "numeric" type)
|
||||
var x string
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
func (s *ExportService) writeExportZipStream(ctx context.Context, w io.Writer, dbData DatabaseExport) error {
|
||||
zipWriter := zip.NewWriter(w)
|
||||
|
||||
// Add database.json
|
||||
jsonWriter, err := zipWriter.Create("database.json")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create database.json in zip: %w", err)
|
||||
}
|
||||
|
||||
jsonEncoder := json.NewEncoder(jsonWriter)
|
||||
jsonEncoder.SetEscapeHTML(false)
|
||||
|
||||
if err := jsonEncoder.Encode(dbData); err != nil {
|
||||
return fmt.Errorf("failed to encode database.json: %w", err)
|
||||
}
|
||||
|
||||
// Add uploaded files
|
||||
if err := s.addUploadsToZip(ctx, zipWriter); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return zipWriter.Close()
|
||||
}
|
||||
|
||||
// addUploadsToZip adds all files from the storage to the ZIP archive under the "uploads/" directory
|
||||
func (s *ExportService) addUploadsToZip(ctx context.Context, zipWriter *zip.Writer) error {
|
||||
return s.storage.Walk(ctx, "/", func(p storage.ObjectInfo) error {
|
||||
zipPath := filepath.Join("uploads", p.Path)
|
||||
|
||||
w, err := zipWriter.Create(zipPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create zip entry for %s: %w", zipPath, err)
|
||||
}
|
||||
|
||||
f, _, err := s.storage.Open(ctx, p.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open file %s: %w", zipPath, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.Copy(w, f); err != nil {
|
||||
return fmt.Errorf("failed to copy file %s into zip: %w", zipPath, err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
264
backend/internal/service/import_service.go
Normal file
264
backend/internal/service/import_service.go
Normal file
@@ -0,0 +1,264 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
// ImportService handles importing Pocket ID data from an exported ZIP archive.
|
||||
type ImportService struct {
|
||||
db *gorm.DB
|
||||
storage storage.FileStorage
|
||||
}
|
||||
|
||||
type DatabaseExport struct {
|
||||
Provider string `json:"provider"`
|
||||
Version uint `json:"version"`
|
||||
Tables map[string][]map[string]any `json:"tables"`
|
||||
TableOrder []string `json:"tableOrder"`
|
||||
}
|
||||
|
||||
func NewImportService(db *gorm.DB, storage storage.FileStorage) *ImportService {
|
||||
return &ImportService{
|
||||
db: db,
|
||||
storage: storage,
|
||||
}
|
||||
}
|
||||
|
||||
// ImportFromZip performs the full import process from the given ZIP reader.
|
||||
func (s *ImportService) ImportFromZip(ctx context.Context, r *zip.Reader) error {
|
||||
dbData, err := processZipDatabaseJson(r.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.ImportDatabase(dbData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.importUploads(ctx, r.File)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ImportDatabase only imports the database data from the given DatabaseExport struct.
|
||||
func (s *ImportService) ImportDatabase(dbData DatabaseExport) error {
|
||||
err := s.resetSchema(dbData.Version, dbData.Provider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.insertData(dbData)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processZipDatabaseJson extracts database.json from the ZIP archive
|
||||
func processZipDatabaseJson(files []*zip.File) (dbData DatabaseExport, err error) {
|
||||
for _, f := range files {
|
||||
if f.Name == "database.json" {
|
||||
return parseDatabaseJsonStream(f)
|
||||
}
|
||||
}
|
||||
return dbData, errors.New("database.json not found in the ZIP file")
|
||||
}
|
||||
|
||||
func parseDatabaseJsonStream(f *zip.File) (dbData DatabaseExport, err error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return dbData, fmt.Errorf("failed to open database.json: %w", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
|
||||
err = json.NewDecoder(rc).Decode(&dbData)
|
||||
if err != nil {
|
||||
return dbData, fmt.Errorf("failed to decode database.json: %w", err)
|
||||
}
|
||||
|
||||
return dbData, nil
|
||||
}
|
||||
|
||||
// importUploads imports files from the uploads/ directory in the ZIP archive
|
||||
func (s *ImportService) importUploads(ctx context.Context, files []*zip.File) error {
|
||||
const maxFileSize = 50 << 20 // 50 MiB
|
||||
const uploadsPrefix = "uploads/"
|
||||
|
||||
for _, f := range files {
|
||||
if !strings.HasPrefix(f.Name, uploadsPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
if f.UncompressedSize64 > maxFileSize {
|
||||
return fmt.Errorf("file %s too large (%d bytes)", f.Name, f.UncompressedSize64)
|
||||
}
|
||||
|
||||
targetPath := strings.TrimPrefix(f.Name, uploadsPrefix)
|
||||
if strings.HasSuffix(f.Name, "/") || targetPath == "" {
|
||||
continue // Skip directories
|
||||
}
|
||||
|
||||
err := s.storage.DeleteAll(ctx, targetPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete existing file %s: %w", targetPath, err)
|
||||
}
|
||||
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
buf, err := io.ReadAll(rc)
|
||||
rc.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("read file %s: %w", f.Name, err)
|
||||
}
|
||||
|
||||
err = s.storage.Save(ctx, targetPath, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save file %s: %w", targetPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// resetSchema drops the existing schema and migrates to the target version
|
||||
func (s *ImportService) resetSchema(targetVersion uint, exportDbProvider string) error {
|
||||
sqlDb, err := s.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get sql.DB: %w", err)
|
||||
}
|
||||
|
||||
m, err := utils.GetEmbeddedMigrateInstance(sqlDb)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get migrate instance: %w", err)
|
||||
}
|
||||
|
||||
err = m.Drop()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to drop existing schema: %w", err)
|
||||
}
|
||||
|
||||
// Needs to be called again to re-create the schema_migrations table
|
||||
m, err = utils.GetEmbeddedMigrateInstance(sqlDb)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get migrate instance: %w", err)
|
||||
}
|
||||
|
||||
err = m.Migrate(targetVersion)
|
||||
if err != nil {
|
||||
return fmt.Errorf("migration failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// insertData populates the DB with the imported data
|
||||
func (s *ImportService) insertData(dbData DatabaseExport) error {
|
||||
schema, err := utils.LoadDBSchemaTypes(s.db)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load schema types: %w", err)
|
||||
}
|
||||
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Iterate through all tables
|
||||
// Some tables need to be processed in order
|
||||
tables := make([]string, 0, len(dbData.Tables))
|
||||
tables = append(tables, dbData.TableOrder...)
|
||||
|
||||
for t := range dbData.Tables {
|
||||
// Skip tables already present where the order matters
|
||||
// Also skip the schema_migrations table
|
||||
if slices.Contains(dbData.TableOrder, t) || t == "schema_migrations" {
|
||||
continue
|
||||
}
|
||||
tables = append(tables, t)
|
||||
}
|
||||
|
||||
// Insert rows
|
||||
for _, table := range tables {
|
||||
for _, row := range dbData.Tables[table] {
|
||||
err = normalizeRowWithSchema(row, table, schema)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to normalize row for table '%s': %w", table, err)
|
||||
}
|
||||
err = tx.Table(table).Create(row).Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed inserting into table '%s': %w", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// normalizeRowWithSchema converts row values based on the DB schema
|
||||
func normalizeRowWithSchema(row map[string]any, table string, schema utils.DBSchemaTypes) error {
|
||||
if schema[table] == nil {
|
||||
return fmt.Errorf("schema not found for table '%s'", table)
|
||||
}
|
||||
|
||||
for col, val := range row {
|
||||
if val == nil {
|
||||
// If the value is nil, skip the column
|
||||
continue
|
||||
}
|
||||
|
||||
colType := schema[table][col]
|
||||
|
||||
switch colType.Name {
|
||||
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
|
||||
// Dates are stored as strings
|
||||
str, ok := val.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("value for column '%s/%s' was expected to be a string, but was '%T'", table, col, val)
|
||||
}
|
||||
d, err := datatype.DateTimeFromString(str)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode value for column '%s/%s' as timestamp: %w", table, col, err)
|
||||
}
|
||||
row[col] = d
|
||||
|
||||
case "blob", "bytea", "jsonb":
|
||||
// Binary data and jsonb data is stored in the file as base64-encoded string
|
||||
str, ok := val.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("value for column '%s/%s' was expected to be a string, but was '%T'", table, col, val)
|
||||
}
|
||||
b, err := base64.StdEncoding.DecodeString(str)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode value for column '%s/%s' from base64: %w", table, col, err)
|
||||
}
|
||||
|
||||
// For jsonb, we additionally cast to json.RawMessage
|
||||
if colType.Name == "jsonb" {
|
||||
row[col] = json.RawMessage(b)
|
||||
} else {
|
||||
row[col] = b
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -18,14 +18,6 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
// PrivateKeyFile is the path in the data/keys folder where the key is stored
|
||||
// This is a JSON file containing a key encoded as JWK
|
||||
PrivateKeyFile = "jwt_private_key.json"
|
||||
|
||||
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
|
||||
// This is a encrypted JSON file containing a key encoded as JWK
|
||||
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
|
||||
|
||||
// KeyUsageSigning is the usage for the private keys, for the "use" property
|
||||
KeyUsageSigning = "sig"
|
||||
|
||||
@@ -56,6 +48,7 @@ const (
|
||||
)
|
||||
|
||||
type JwtService struct {
|
||||
db *gorm.DB
|
||||
envConfig *common.EnvConfigSchema
|
||||
privateKey jwk.Key
|
||||
keyId string
|
||||
@@ -66,7 +59,6 @@ type JwtService struct {
|
||||
func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService, error) {
|
||||
service := &JwtService{}
|
||||
|
||||
// Ensure keys are generated or loaded
|
||||
err := service.init(db, appConfigService, &common.EnvConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -78,14 +70,15 @@ func NewJwtService(db *gorm.DB, appConfigService *AppConfigService) (*JwtService
|
||||
func (s *JwtService) init(db *gorm.DB, appConfigService *AppConfigService, envConfig *common.EnvConfigSchema) (err error) {
|
||||
s.appConfigService = appConfigService
|
||||
s.envConfig = envConfig
|
||||
s.db = db
|
||||
|
||||
// Ensure keys are generated or loaded
|
||||
return s.loadOrGenerateKey(db)
|
||||
return s.LoadOrGenerateKey()
|
||||
}
|
||||
|
||||
func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
|
||||
func (s *JwtService) LoadOrGenerateKey() error {
|
||||
// Get the key provider
|
||||
keyProvider, err := jwkutils.GetKeyProvider(db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
||||
keyProvider, err := jwkutils.GetKeyProvider(s.db, s.envConfig, s.appConfigService.GetDbConfig().InstanceID.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get key provider: %w", err)
|
||||
}
|
||||
@@ -93,7 +86,7 @@ func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
|
||||
// Try loading a key
|
||||
key, err := keyProvider.LoadKey()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
|
||||
return fmt.Errorf("failed to load key: %w", err)
|
||||
}
|
||||
|
||||
// If we have a key, store it in the object and we're done
|
||||
@@ -114,7 +107,7 @@ func (s *JwtService) loadOrGenerateKey(db *gorm.DB) error {
|
||||
// Save the newly-generated key
|
||||
err = keyProvider.SaveKey(s.privateKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save private key (provider type '%s'): %w", s.envConfig.KeysStorage, err)
|
||||
return fmt.Errorf("failed to save private key: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -371,7 +371,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
// Check if user is admin by checking if they are in the admin group
|
||||
isAdmin := false
|
||||
for _, group := range value.GetAttributeValues("memberOf") {
|
||||
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAttributeAdminGroup.Value {
|
||||
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
|
||||
@@ -148,6 +148,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
var err error
|
||||
// Create a test database
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
|
||||
// Create two JWKs for testing
|
||||
privateJWK, jwkSetJSON := generateTestECDSAKey(t)
|
||||
|
||||
@@ -18,13 +18,14 @@ import (
|
||||
)
|
||||
|
||||
type S3Config struct {
|
||||
Bucket string
|
||||
Region string
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
ForcePathStyle bool
|
||||
Root string
|
||||
Bucket string
|
||||
Region string
|
||||
Endpoint string
|
||||
AccessKeyID string
|
||||
SecretAccessKey string
|
||||
ForcePathStyle bool
|
||||
DisableDefaultIntegrityChecks bool
|
||||
Root string
|
||||
}
|
||||
|
||||
type s3Storage struct {
|
||||
@@ -44,6 +45,10 @@ 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{
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
var (
|
||||
TypeFileSystem = "fs"
|
||||
TypeFileSystem = "filesystem"
|
||||
TypeS3 = "s3"
|
||||
)
|
||||
|
||||
|
||||
130
backend/internal/utils/db_migration_util.go
Normal file
130
backend/internal/utils/db_migration_util.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
"github.com/golang-migrate/migrate/v4/database"
|
||||
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/resources"
|
||||
)
|
||||
|
||||
// MigrateDatabase applies database migrations using embedded migration files or fetches them from GitHub if a downgrade is detected.
|
||||
func MigrateDatabase(sqlDb *sql.DB) error {
|
||||
m, err := GetEmbeddedMigrateInstance(sqlDb)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get migrate instance: %w", err)
|
||||
}
|
||||
|
||||
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||
requiredVersion, err := getRequiredMigrationVersion(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get last migration version: %w", err)
|
||||
}
|
||||
|
||||
currentVersion, _, _ := m.Version()
|
||||
if currentVersion > requiredVersion {
|
||||
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
|
||||
if !common.EnvConfig.AllowDowngrade {
|
||||
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
|
||||
}
|
||||
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
|
||||
return migrateDatabaseFromGitHub(sqlDb, requiredVersion)
|
||||
}
|
||||
|
||||
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply embedded migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetEmbeddedMigrateInstance creates a migrate.Migrate instance using embedded migration files.
|
||||
func GetEmbeddedMigrateInstance(sqlDb *sql.DB) (*migrate.Migrate, error) {
|
||||
path := "migrations/" + string(common.EnvConfig.DbProvider)
|
||||
source, err := iofs.New(resources.FS, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create embedded migration source: %w", err)
|
||||
}
|
||||
|
||||
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create migration instance: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// newMigrationDriver creates a database.Driver instance based on the given database provider.
|
||||
func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver database.Driver, err error) {
|
||||
switch dbProvider {
|
||||
case common.DbProviderSqlite:
|
||||
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
|
||||
NoTxWrap: true,
|
||||
})
|
||||
case common.DbProviderPostgres:
|
||||
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
|
||||
default:
|
||||
// Should never happen at this point
|
||||
return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
return driver, nil
|
||||
}
|
||||
|
||||
// migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades.
|
||||
func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error {
|
||||
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
|
||||
|
||||
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration driver: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
|
||||
func getRequiredMigrationVersion(path string) (uint, error) {
|
||||
entries, err := resources.FS.ReadDir(path)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to read migration directory: %w", err)
|
||||
}
|
||||
|
||||
var maxVersion uint
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
var version uint
|
||||
n, err := fmt.Sscanf(name, "%d_", &version)
|
||||
if err == nil && n == 1 {
|
||||
if version > maxVersion {
|
||||
maxVersion = version
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return maxVersion, nil
|
||||
}
|
||||
116
backend/internal/utils/db_util.go
Normal file
116
backend/internal/utils/db_util.go
Normal file
@@ -0,0 +1,116 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DBTableExists checks if a table exists in the database
|
||||
func DBTableExists(db *gorm.DB, tableName string) (exists bool, err error) {
|
||||
switch db.Name() {
|
||||
case "postgres":
|
||||
query := `SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = ?
|
||||
)`
|
||||
err = db.Raw(query, tableName).Scan(&exists).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
case "sqlite":
|
||||
query := `SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name=?`
|
||||
err = db.Raw(query, tableName).Scan(&exists).Error
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
default:
|
||||
return false, fmt.Errorf("unsupported database dialect: %s", db.Name())
|
||||
}
|
||||
|
||||
return exists, nil
|
||||
}
|
||||
|
||||
type DBSchemaColumn struct {
|
||||
Name string
|
||||
Nullable bool
|
||||
}
|
||||
type DBSchemaTableTypes = map[string]DBSchemaColumn
|
||||
type DBSchemaTypes = map[string]DBSchemaTableTypes
|
||||
|
||||
// LoadDBSchemaTypes retrieves the column types for all tables in the DB
|
||||
// Result is a map of "table --> column --> {name: column type name, nullable: boolean}"
|
||||
func LoadDBSchemaTypes(db *gorm.DB) (result DBSchemaTypes, err error) {
|
||||
result = make(DBSchemaTypes)
|
||||
|
||||
switch db.Name() {
|
||||
case "postgres":
|
||||
var rows []struct {
|
||||
TableName string
|
||||
ColumnName string
|
||||
DataType string
|
||||
Nullable bool
|
||||
}
|
||||
err := db.
|
||||
Raw(`
|
||||
SELECT table_name, column_name, data_type, is_nullable = 'YES' AS nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = 'public';
|
||||
`).
|
||||
Scan(&rows).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
t := strings.ToLower(r.DataType)
|
||||
if result[r.TableName] == nil {
|
||||
result[r.TableName] = make(map[string]DBSchemaColumn)
|
||||
}
|
||||
result[r.TableName][r.ColumnName] = DBSchemaColumn{
|
||||
Name: strings.ToLower(t),
|
||||
Nullable: r.Nullable,
|
||||
}
|
||||
}
|
||||
|
||||
case "sqlite":
|
||||
var tables []string
|
||||
err = db.
|
||||
Raw(`SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%';`).
|
||||
Scan(&tables).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, table := range tables {
|
||||
var cols []struct {
|
||||
Name string
|
||||
Type string
|
||||
Notnull bool
|
||||
}
|
||||
err := db.
|
||||
Raw(`PRAGMA table_info("` + table + `");`).
|
||||
Scan(&cols).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, c := range cols {
|
||||
if result[table] == nil {
|
||||
result[table] = make(map[string]DBSchemaColumn)
|
||||
}
|
||||
result[table][c.Name] = DBSchemaColumn{
|
||||
Name: strings.ToLower(c.Type),
|
||||
Nullable: !c.Notnull,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported database dialect: %s", db.Name())
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
@@ -28,22 +28,14 @@ func GetKeyProvider(db *gorm.DB, envConfig *common.EnvConfigSchema, instanceID s
|
||||
return nil, fmt.Errorf("failed to load encryption key: %w", err)
|
||||
}
|
||||
|
||||
// Get the key provider
|
||||
switch envConfig.KeysStorage {
|
||||
case "file", "":
|
||||
keyProvider = &KeyProviderFile{}
|
||||
case "database":
|
||||
keyProvider = &KeyProviderDatabase{}
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid key storage '%s'", envConfig.KeysStorage)
|
||||
}
|
||||
keyProvider = &KeyProviderDatabase{}
|
||||
err = keyProvider.Init(KeyProviderOpts{
|
||||
DB: db,
|
||||
EnvConfig: envConfig,
|
||||
Kek: kek,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to init key provider of type '%s': %w", envConfig.KeysStorage, err)
|
||||
return nil, fmt.Errorf("failed to init key provider: %w", err)
|
||||
}
|
||||
|
||||
return keyProvider, nil
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||
)
|
||||
|
||||
const (
|
||||
// PrivateKeyFile is the path in the data/keys folder where the key is stored
|
||||
// This is a JSON file containing a key encoded as JWK
|
||||
PrivateKeyFile = "jwt_private_key.json"
|
||||
|
||||
// PrivateKeyFileEncrypted is the path in the data/keys folder where the encrypted key is stored
|
||||
// This is a encrypted JSON file containing a key encoded as JWK
|
||||
PrivateKeyFileEncrypted = "jwt_private_key.json.enc"
|
||||
)
|
||||
|
||||
type KeyProviderFile struct {
|
||||
envConfig *common.EnvConfigSchema
|
||||
kek []byte
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) Init(opts KeyProviderOpts) error {
|
||||
f.envConfig = opts.EnvConfig
|
||||
f.kek = opts.Kek
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) LoadKey() (jwk.Key, error) {
|
||||
if len(f.kek) > 0 {
|
||||
return f.loadEncryptedKey()
|
||||
}
|
||||
return f.loadKey()
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) SaveKey(key jwk.Key) error {
|
||||
if len(f.kek) > 0 {
|
||||
return f.saveKeyEncrypted(key)
|
||||
}
|
||||
return f.saveKey(key)
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) loadKey() (jwk.Key, error) {
|
||||
var key jwk.Key
|
||||
|
||||
// First, check if we have a JWK file
|
||||
// If we do, then we just load that
|
||||
jwkPath := f.jwkPath()
|
||||
ok, err := utils.FileExists(jwkPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if private key file exists at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
if !ok {
|
||||
// File doesn't exist, no key was loaded
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(jwkPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read private key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
key, err = jwk.ParseKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse private key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) loadEncryptedKey() (key jwk.Key, err error) {
|
||||
// First, check if we have an encrypted JWK file
|
||||
// If we do, then we just load that
|
||||
encJwkPath := f.encJwkPath()
|
||||
ok, err := utils.FileExists(encJwkPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check if encrypted private key file exists at path '%s': %w", encJwkPath, err)
|
||||
}
|
||||
if ok {
|
||||
encB64, err := os.ReadFile(encJwkPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': %w", encJwkPath, err)
|
||||
}
|
||||
|
||||
// Decode from base64
|
||||
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
|
||||
n, err := base64.StdEncoding.Decode(enc, encB64)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read encrypted private key file at path '%s': not a valid base64-encoded file: %w", encJwkPath, err)
|
||||
}
|
||||
|
||||
// Decrypt the data
|
||||
data, err := cryptoutils.Decrypt(f.kek, enc[:n], nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to decrypt private key file at path '%s': %w", encJwkPath, err)
|
||||
}
|
||||
|
||||
// Parse the key
|
||||
key, err = jwk.ParseKey(data)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse encrypted private key file at path '%s': %w", encJwkPath, err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
// Check if we have an un-encrypted JWK file
|
||||
key, err = f.loadKey()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to load un-encrypted key file: %w", err)
|
||||
}
|
||||
if key == nil {
|
||||
// No key exists, encrypted or un-encrypted
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// If we are here, we have loaded a key that was un-encrypted
|
||||
// We need to replace the plaintext key with the encrypted one before we return
|
||||
err = f.saveKeyEncrypted(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save encrypted key file: %w", err)
|
||||
}
|
||||
jwkPath := f.jwkPath()
|
||||
err = os.Remove(jwkPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to remove un-encrypted key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
return key, nil
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) saveKey(key jwk.Key) error {
|
||||
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory '%s' for key file: %w", f.envConfig.KeysPath, err)
|
||||
}
|
||||
|
||||
jwkPath := f.jwkPath()
|
||||
keyFile, err := os.OpenFile(jwkPath, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
defer keyFile.Close()
|
||||
|
||||
// Write the JSON file to disk
|
||||
err = EncodeJWK(keyFile, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write key file at path '%s': %w", jwkPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) saveKeyEncrypted(key jwk.Key) error {
|
||||
err := os.MkdirAll(f.envConfig.KeysPath, 0700)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create directory '%s' for encrypted key file: %w", f.envConfig.KeysPath, err)
|
||||
}
|
||||
|
||||
// Encode the key to JSON
|
||||
data, err := EncodeJWKBytes(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encode key to JSON: %w", err)
|
||||
}
|
||||
|
||||
// Encrypt the key then encode to Base64
|
||||
enc, err := cryptoutils.Encrypt(f.kek, data, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to encrypt key: %w", err)
|
||||
}
|
||||
encB64 := make([]byte, base64.StdEncoding.EncodedLen(len(enc)))
|
||||
base64.StdEncoding.Encode(encB64, enc)
|
||||
|
||||
// Write to disk
|
||||
encJwkPath := f.encJwkPath()
|
||||
err = os.WriteFile(encJwkPath, encB64, 0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write encrypted key file at path '%s': %w", encJwkPath, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) jwkPath() string {
|
||||
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFile)
|
||||
}
|
||||
|
||||
func (f *KeyProviderFile) encJwkPath() string {
|
||||
return filepath.Join(f.envConfig.KeysPath, PrivateKeyFileEncrypted)
|
||||
}
|
||||
|
||||
// Compile-time interface check
|
||||
var _ KeyProvider = (*KeyProviderFile)(nil)
|
||||
@@ -1,320 +0,0 @@
|
||||
package jwk
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
|
||||
)
|
||||
|
||||
func TestKeyProviderFile_LoadKey(t *testing.T) {
|
||||
// Generate a test key to use in our tests
|
||||
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := jwk.Import(pk)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("LoadKey with no existing key", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
provider := &KeyProviderFile{}
|
||||
err := provider.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load key when none exists
|
||||
loadedKey, err := provider.LoadKey()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
|
||||
})
|
||||
|
||||
t.Run("LoadKey with no existing key (with kek)", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
provider := &KeyProviderFile{}
|
||||
err = provider.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
Kek: makeKEK(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load key when none exists
|
||||
loadedKey, err := provider.LoadKey()
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, loadedKey, "Expected nil key when no key exists")
|
||||
})
|
||||
|
||||
t.Run("LoadKey with unencrypted key", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
provider := &KeyProviderFile{}
|
||||
err := provider.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save a key
|
||||
err = provider.SaveKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Make sure the key file exists
|
||||
keyPath := filepath.Join(tempDir, PrivateKeyFile)
|
||||
exists, err := utils.FileExists(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Expected key file to exist")
|
||||
|
||||
// Load the key
|
||||
loadedKey, err := provider.LoadKey()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, loadedKey, "Expected non-nil key when key exists")
|
||||
|
||||
// Verify the loaded key is the same as the original
|
||||
keyBytes, err := EncodeJWKBytes(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
|
||||
})
|
||||
|
||||
t.Run("LoadKey with encrypted key", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
provider := &KeyProviderFile{}
|
||||
err = provider.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
Kek: makeKEK(t),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save a key (will be encrypted)
|
||||
err = provider.SaveKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Make sure the encrypted key file exists
|
||||
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
|
||||
exists, err := utils.FileExists(encKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Expected encrypted key file to exist")
|
||||
|
||||
// Make sure the unencrypted key file does not exist
|
||||
keyPath := filepath.Join(tempDir, PrivateKeyFile)
|
||||
exists, err = utils.FileExists(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Expected unencrypted key file to not exist")
|
||||
|
||||
// Load the key
|
||||
loadedKey, err := provider.LoadKey()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, loadedKey, "Expected non-nil key when encrypted key exists")
|
||||
|
||||
// Verify the loaded key is the same as the original
|
||||
keyBytes, err := EncodeJWKBytes(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key")
|
||||
})
|
||||
|
||||
t.Run("LoadKey replaces unencrypted key with encrypted key when kek is provided", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// First, create an unencrypted key
|
||||
providerNoKek := &KeyProviderFile{}
|
||||
err := providerNoKek.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save an unencrypted key
|
||||
err = providerNoKek.SaveKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify unencrypted key exists
|
||||
keyPath := filepath.Join(tempDir, PrivateKeyFile)
|
||||
exists, err := utils.FileExists(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Expected unencrypted key file to exist")
|
||||
|
||||
// Now create a provider with a kek
|
||||
kek := make([]byte, 32)
|
||||
_, err = rand.Read(kek)
|
||||
require.NoError(t, err)
|
||||
|
||||
providerWithKek := &KeyProviderFile{}
|
||||
err = providerWithKek.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
Kek: kek,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Load the key - this should convert the unencrypted key to encrypted
|
||||
loadedKey, err := providerWithKek.LoadKey()
|
||||
require.NoError(t, err)
|
||||
assert.NotNil(t, loadedKey, "Expected non-nil key when loading and converting key")
|
||||
|
||||
// Verify the unencrypted key no longer exists
|
||||
exists, err = utils.FileExists(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Expected unencrypted key file to be removed")
|
||||
|
||||
// Verify the encrypted key file exists
|
||||
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
|
||||
exists, err = utils.FileExists(encKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Expected encrypted key file to exist after conversion")
|
||||
|
||||
// Verify the key data
|
||||
keyBytes, err := EncodeJWKBytes(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
loadedKeyBytes, err := EncodeJWKBytes(loadedKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, keyBytes, loadedKeyBytes, "Expected loaded key to match original key after conversion")
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeyProviderFile_SaveKey(t *testing.T) {
|
||||
// Generate a test key to use in our tests
|
||||
pk, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
key, err := jwk.Import(pk)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("SaveKey unencrypted", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
provider := &KeyProviderFile{}
|
||||
err := provider.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save the key
|
||||
err = provider.SaveKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the key file exists
|
||||
keyPath := filepath.Join(tempDir, PrivateKeyFile)
|
||||
exists, err := utils.FileExists(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Expected key file to exist")
|
||||
|
||||
// Verify the content of the key file
|
||||
data, err := os.ReadFile(keyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsedKey, err := jwk.ParseKey(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare the saved key with the original
|
||||
keyBytes, err := EncodeJWKBytes(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected saved key to match original key")
|
||||
})
|
||||
|
||||
t.Run("SaveKey encrypted", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
// Generate a 64-byte kek
|
||||
kek := makeKEK(t)
|
||||
|
||||
provider := &KeyProviderFile{}
|
||||
err = provider.Init(KeyProviderOpts{
|
||||
EnvConfig: &common.EnvConfigSchema{
|
||||
KeysPath: tempDir,
|
||||
},
|
||||
Kek: kek,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Save the key (will be encrypted)
|
||||
err = provider.SaveKey(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify the encrypted key file exists
|
||||
encKeyPath := filepath.Join(tempDir, PrivateKeyFileEncrypted)
|
||||
exists, err := utils.FileExists(encKeyPath)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, exists, "Expected encrypted key file to exist")
|
||||
|
||||
// Verify the unencrypted key file doesn't exist
|
||||
keyPath := filepath.Join(tempDir, PrivateKeyFile)
|
||||
exists, err = utils.FileExists(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Expected unencrypted key file to not exist")
|
||||
|
||||
// Manually decrypt the encrypted key file to verify it contains the correct key
|
||||
encB64, err := os.ReadFile(encKeyPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Decode from base64
|
||||
enc := make([]byte, base64.StdEncoding.DecodedLen(len(encB64)))
|
||||
n, err := base64.StdEncoding.Decode(enc, encB64)
|
||||
require.NoError(t, err)
|
||||
enc = enc[:n] // Trim any padding
|
||||
|
||||
// Decrypt the data
|
||||
data, err := cryptoutils.Decrypt(kek, enc, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Parse the key
|
||||
parsedKey, err := jwk.ParseKey(data)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Compare the decrypted key with the original
|
||||
keyBytes, err := EncodeJWKBytes(key)
|
||||
require.NoError(t, err)
|
||||
|
||||
parsedKeyBytes, err := EncodeJWKBytes(parsedKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, keyBytes, parsedKeyBytes, "Expected decrypted key to match original key")
|
||||
})
|
||||
}
|
||||
|
||||
func makeKEK(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
|
||||
// Generate a 32-byte kek
|
||||
kek := make([]byte, 32)
|
||||
_, err := rand.Read(kek)
|
||||
require.NoError(t, err)
|
||||
return kek
|
||||
}
|
||||
@@ -38,6 +38,7 @@ func (r *ServiceRunner) Run(ctx context.Context) error {
|
||||
|
||||
// Ignore context canceled errors here as they generally indicate that the service is stopping
|
||||
if rErr != nil && !errors.Is(rErr, context.Canceled) {
|
||||
cancel()
|
||||
errCh <- rErr
|
||||
return
|
||||
}
|
||||
|
||||
@@ -61,6 +61,26 @@ func TestServiceRunner_Run(t *testing.T) {
|
||||
require.ErrorIs(t, err, expectedErr)
|
||||
})
|
||||
|
||||
t.Run("service error cancels others", func(t *testing.T) {
|
||||
expectedErr := errors.New("boom")
|
||||
errorService := func(ctx context.Context) error {
|
||||
return expectedErr
|
||||
}
|
||||
waitingService := func(ctx context.Context) error {
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
runner := NewServiceRunner(errorService, waitingService)
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := runner.Run(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, expectedErr)
|
||||
})
|
||||
|
||||
t.Run("context canceled", func(t *testing.T) {
|
||||
// Create a service that waits until context is canceled
|
||||
waitingService := func(ctx context.Context) error {
|
||||
|
||||
File diff suppressed because one or more lines are too long
1
backend/resources/e2e-tests/database.json
Symbolic link
1
backend/resources/e2e-tests/database.json
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../tests/database.json
|
||||
72
backend/resources/files_test.go
Normal file
72
backend/resources/files_test.go
Normal file
@@ -0,0 +1,72 @@
|
||||
package resources
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// This test is meant to enforce that for every new migration added, a file with the same migration number exists for all supported databases
|
||||
// This is necessary to ensure import/export works correctly
|
||||
// Note: if a migration is not needed for a database, ensure there's a file with an empty (no-op) migration (e.g. even just a comment)
|
||||
func TestMigrationsMatchingVersions(t *testing.T) {
|
||||
// We can ignore migrations with version below 20251115000000
|
||||
const ignoreBefore = 20251115000000
|
||||
|
||||
// Scan postgres migrations
|
||||
postgresMigrations := scanMigrations(t, FS, "migrations/postgres", ignoreBefore)
|
||||
|
||||
// Scan sqlite migrations
|
||||
sqliteMigrations := scanMigrations(t, FS, "migrations/sqlite", ignoreBefore)
|
||||
|
||||
// Sort both lists for consistent comparison
|
||||
slices.Sort(postgresMigrations)
|
||||
slices.Sort(sqliteMigrations)
|
||||
|
||||
// Compare the lists
|
||||
assert.Equal(t, postgresMigrations, sqliteMigrations, "Migration versions must match between Postgres and SQLite")
|
||||
}
|
||||
|
||||
// scanMigrations scans a directory for migration files and returns a list of versions
|
||||
func scanMigrations(t *testing.T, fs embed.FS, dir string, ignoreBefore int64) []int64 {
|
||||
t.Helper()
|
||||
|
||||
entries, err := fs.ReadDir(dir)
|
||||
require.NoErrorf(t, err, "Failed to read directory '%s'", dir)
|
||||
|
||||
// Divide by 2 because of up and down files
|
||||
versions := make([]int64, 0, len(entries)/2)
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
filename := entry.Name()
|
||||
|
||||
// Only consider .up.sql files
|
||||
if !strings.HasSuffix(filename, ".up.sql") {
|
||||
continue
|
||||
}
|
||||
|
||||
// Extract version from filename (format: <version>_<anything>.up.sql)
|
||||
versionString, _, ok := strings.Cut(filename, "_")
|
||||
require.Truef(t, ok, "Migration file has unexpected format: %s", filename)
|
||||
|
||||
version, err := strconv.ParseInt(versionString, 10, 64)
|
||||
require.NoErrorf(t, err, "Failed to parse version from filename '%s'", filename)
|
||||
|
||||
// Exclude migrations with version below ignoreBefore
|
||||
if version < ignoreBefore {
|
||||
continue
|
||||
}
|
||||
|
||||
versions = append(versions, version)
|
||||
}
|
||||
|
||||
return versions
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op in Postgres
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op in Postgres
|
||||
@@ -0,0 +1,133 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
BEGIN;
|
||||
|
||||
CREATE TABLE users_old
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
username TEXT COLLATE NOCASE NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
first_name TEXT,
|
||||
last_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
is_admin NUMERIC DEFAULT 0 NOT NULL,
|
||||
ldap_id TEXT,
|
||||
locale TEXT,
|
||||
disabled NUMERIC DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO users_old (
|
||||
id,
|
||||
created_at,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
is_admin,
|
||||
ldap_id,
|
||||
locale,
|
||||
disabled
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
CASE WHEN is_admin THEN 1 ELSE 0 END,
|
||||
ldap_id,
|
||||
locale,
|
||||
CASE WHEN disabled THEN 1 ELSE 0 END
|
||||
FROM users;
|
||||
|
||||
DROP TABLE users;
|
||||
|
||||
ALTER TABLE users_old RENAME TO users;
|
||||
|
||||
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
|
||||
|
||||
|
||||
|
||||
CREATE TABLE webauthn_credentials_old
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id TEXT NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type TEXT NOT NULL,
|
||||
transport BLOB NOT NULL,
|
||||
user_id TEXT REFERENCES users ON DELETE CASCADE,
|
||||
backup_eligible NUMERIC DEFAULT 0 NOT NULL,
|
||||
backup_state NUMERIC DEFAULT 0 NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO webauthn_credentials_old (
|
||||
id,
|
||||
created_at,
|
||||
name,
|
||||
credential_id,
|
||||
public_key,
|
||||
attestation_type,
|
||||
transport,
|
||||
user_id,
|
||||
backup_eligible,
|
||||
backup_state
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
name,
|
||||
credential_id,
|
||||
public_key,
|
||||
attestation_type,
|
||||
transport,
|
||||
user_id,
|
||||
CASE WHEN backup_eligible THEN 1 ELSE 0 END,
|
||||
CASE WHEN backup_state THEN 1 ELSE 0 END
|
||||
FROM webauthn_credentials;
|
||||
|
||||
DROP TABLE webauthn_credentials;
|
||||
|
||||
ALTER TABLE webauthn_credentials_old RENAME TO webauthn_credentials;
|
||||
|
||||
|
||||
|
||||
CREATE TABLE webauthn_sessions_old
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
challenge TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
user_verification TEXT NOT NULL,
|
||||
credential_params TEXT DEFAULT '[]' NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO webauthn_sessions_old (
|
||||
id,
|
||||
created_at,
|
||||
challenge,
|
||||
expires_at,
|
||||
user_verification,
|
||||
credential_params
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
challenge,
|
||||
expires_at,
|
||||
user_verification,
|
||||
credential_params
|
||||
FROM webauthn_sessions;
|
||||
|
||||
DROP TABLE webauthn_sessions;
|
||||
|
||||
ALTER TABLE webauthn_sessions_old RENAME TO webauthn_sessions;
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -0,0 +1,144 @@
|
||||
PRAGMA foreign_keys = OFF;
|
||||
|
||||
BEGIN;
|
||||
|
||||
-- 1. Create a new table with BOOLEAN columns
|
||||
CREATE TABLE users_new
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
username TEXT COLLATE NOCASE NOT NULL UNIQUE,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
first_name TEXT,
|
||||
last_name TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
is_admin BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
ldap_id TEXT,
|
||||
locale TEXT,
|
||||
disabled BOOLEAN DEFAULT FALSE NOT NULL
|
||||
);
|
||||
|
||||
-- 2. Copy all existing data, converting numeric bools to real booleans
|
||||
INSERT INTO users_new (
|
||||
id,
|
||||
created_at,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
is_admin,
|
||||
ldap_id,
|
||||
locale,
|
||||
disabled
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
username,
|
||||
email,
|
||||
first_name,
|
||||
last_name,
|
||||
display_name,
|
||||
CASE WHEN is_admin != 0 THEN TRUE ELSE FALSE END,
|
||||
ldap_id,
|
||||
locale,
|
||||
CASE WHEN disabled != 0 THEN TRUE ELSE FALSE END
|
||||
FROM users;
|
||||
|
||||
-- 3. Drop old table
|
||||
DROP TABLE users;
|
||||
|
||||
-- 4. Rename new table to original name
|
||||
ALTER TABLE users_new RENAME TO users;
|
||||
|
||||
-- 5. Recreate index
|
||||
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
|
||||
|
||||
-- 6. Create temporary table with changed credential_id type to BLOB
|
||||
CREATE TABLE webauthn_credentials_dg_tmp
|
||||
(
|
||||
id TEXT PRIMARY KEY,
|
||||
created_at DATETIME NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
credential_id BLOB NOT NULL UNIQUE,
|
||||
public_key BLOB NOT NULL,
|
||||
attestation_type TEXT NOT NULL,
|
||||
transport BLOB NOT NULL,
|
||||
user_id TEXT REFERENCES users ON DELETE CASCADE,
|
||||
backup_eligible BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
backup_state BOOLEAN DEFAULT FALSE NOT NULL
|
||||
);
|
||||
|
||||
-- 7. Copy existing data into the temporary table
|
||||
INSERT INTO webauthn_credentials_dg_tmp (
|
||||
id,
|
||||
created_at,
|
||||
name,
|
||||
credential_id,
|
||||
public_key,
|
||||
attestation_type,
|
||||
transport,
|
||||
user_id,
|
||||
backup_eligible,
|
||||
backup_state
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
name,
|
||||
credential_id,
|
||||
public_key,
|
||||
attestation_type,
|
||||
transport,
|
||||
user_id,
|
||||
backup_eligible,
|
||||
backup_state
|
||||
FROM webauthn_credentials;
|
||||
|
||||
-- 8. Drop old table
|
||||
DROP TABLE webauthn_credentials;
|
||||
|
||||
-- 9. Rename temporary table to original name
|
||||
ALTER TABLE webauthn_credentials_dg_tmp
|
||||
RENAME TO webauthn_credentials;
|
||||
|
||||
-- 10. Create temporary table with credential_params type changed to BLOB
|
||||
CREATE TABLE webauthn_sessions_dg_tmp
|
||||
(
|
||||
id TEXT NOT NULL PRIMARY KEY,
|
||||
created_at DATETIME,
|
||||
challenge TEXT NOT NULL UNIQUE,
|
||||
expires_at DATETIME NOT NULL,
|
||||
user_verification TEXT NOT NULL,
|
||||
credential_params BLOB DEFAULT '[]' NOT NULL
|
||||
);
|
||||
|
||||
-- 11. Copy existing data into the temporary sessions table
|
||||
INSERT INTO webauthn_sessions_dg_tmp (
|
||||
id,
|
||||
created_at,
|
||||
challenge,
|
||||
expires_at,
|
||||
user_verification,
|
||||
credential_params
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
created_at,
|
||||
challenge,
|
||||
expires_at,
|
||||
user_verification,
|
||||
credential_params
|
||||
FROM webauthn_sessions;
|
||||
|
||||
-- 12. Drop old table
|
||||
DROP TABLE webauthn_sessions;
|
||||
|
||||
-- 13. Rename temporary sessions table to original name
|
||||
ALTER TABLE webauthn_sessions_dg_tmp
|
||||
RENAME TO webauthn_sessions;
|
||||
|
||||
COMMIT;
|
||||
|
||||
PRAGMA foreign_keys = ON;
|
||||
@@ -9,17 +9,17 @@
|
||||
"export": "email export"
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "0.1.1",
|
||||
"@react-email/components": "1.0.1",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"tailwind-merge": "^3.3.1"
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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",
|
||||
"@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",
|
||||
"tsx": "^4.20.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"plugins": ["prettier-plugin-tailwindcss", "prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
|
||||
@@ -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 nebo JPEG.",
|
||||
"image_should_be_in_format": "Obrázek by měl být ve formátu PNG, JPEG nebo WEBP.",
|
||||
"items_per_page": "Položek na stránku",
|
||||
"no_items_found": "Nenalezeny žádné položky",
|
||||
"select_items": "Vyberte položky...",
|
||||
|
||||
@@ -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 eller JPEG-format.",
|
||||
"image_should_be_in_format": "Billedet skal være i PNG, JPEG eller WEBP-format.",
|
||||
"items_per_page": "Emner pr. side",
|
||||
"no_items_found": "Ingen emner fundet",
|
||||
"select_items": "Vælg emner...",
|
||||
|
||||
@@ -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- oder JPEG-Format vorliegen.",
|
||||
"image_should_be_in_format": "Das Bild sollte im PNG-, JPEG- oder WEBP-Format vorliegen.",
|
||||
"items_per_page": "Einträge pro Seite",
|
||||
"no_items_found": "Keine Einträge gefunden",
|
||||
"select_items": "Elemente auswählen...",
|
||||
@@ -443,7 +443,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 widerrufen <b>{clientName}</b>. <b>{clientName}</b> kann nicht mehr auf deine Kontoinfos zugreifen.",
|
||||
"revoke_access_description": "Zugriff auf <b>{clientName}</b> widerrufen. <b>{clientName}</b> kann nicht mehr auf deine Kontoinformationen 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.",
|
||||
|
||||
@@ -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 or JPEG format.",
|
||||
"image_should_be_in_format": "The image should be in PNG, JPEG or WEBP format.",
|
||||
"items_per_page": "Items per page",
|
||||
"no_items_found": "No items found",
|
||||
"select_items": "Select items...",
|
||||
|
||||
@@ -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 o JPEG.",
|
||||
"image_should_be_in_format": "La imagen debe ser en formato PNG, JPEG o WEBP.",
|
||||
"items_per_page": "Elementos por página",
|
||||
"no_items_found": "No se encontraron elementos",
|
||||
"select_items": "Seleccionar elementos...",
|
||||
|
||||
@@ -1,473 +1,473 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"my_account": "My Account",
|
||||
"logout": "Logout",
|
||||
"confirm": "Confirm",
|
||||
"docs": "Docs",
|
||||
"key": "Key",
|
||||
"value": "Value",
|
||||
"remove_custom_claim": "Remove custom claim",
|
||||
"add_custom_claim": "Add custom claim",
|
||||
"add_another": "Add another",
|
||||
"select_a_date": "Select a date",
|
||||
"select_file": "Select File",
|
||||
"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 or JPEG format.",
|
||||
"items_per_page": "Items per page",
|
||||
"no_items_found": "No items found",
|
||||
"select_items": "Select items...",
|
||||
"search": "Search...",
|
||||
"expand_card": "Expand card",
|
||||
"copied": "Copied",
|
||||
"click_to_copy": "Click to copy",
|
||||
"something_went_wrong": "Something went wrong",
|
||||
"go_back_to_home": "Go back to home",
|
||||
"alternative_sign_in_methods": "Alternative Sign In Methods",
|
||||
"login_background": "Login background",
|
||||
"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": "Login Code",
|
||||
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
|
||||
"one_hour": "1 hour",
|
||||
"twelve_hours": "12 hours",
|
||||
"one_day": "1 day",
|
||||
"one_week": "1 week",
|
||||
"one_month": "1 month",
|
||||
"expiration": "Expiration",
|
||||
"generate_code": "Generate Code",
|
||||
"name": "Name",
|
||||
"browser_unsupported": "Browser unsupported",
|
||||
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please use an alternative sign in method.",
|
||||
"an_unknown_error_occurred": "An unknown error occurred",
|
||||
"authentication_process_was_aborted": "The authentication process was aborted",
|
||||
"error_occurred_with_authenticator": "An error occurred with the authenticator",
|
||||
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
|
||||
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
|
||||
"passkey_was_previously_registered": "This passkey was previously registered",
|
||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
|
||||
"authenticator_timed_out": "The authenticator timed out",
|
||||
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
|
||||
"sign_in_to": "Sign in to {name}",
|
||||
"client_not_found": "Client not found",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your {appName} account?",
|
||||
"email": "Email",
|
||||
"view_your_email_address": "View your email address",
|
||||
"profile": "Profile",
|
||||
"view_your_profile_information": "View your profile information",
|
||||
"groups": "Groups",
|
||||
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
|
||||
"cancel": "Cancel",
|
||||
"sign_in": "Sign in",
|
||||
"try_again": "Try again",
|
||||
"client_logo": "Client Logo",
|
||||
"sign_out": "Sign out",
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of {appName} with the account <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Sign in to {appName}",
|
||||
"please_try_to_sign_in_again": "Please try to sign in again.",
|
||||
"authenticate_with_passkey_to_access_account": "Authenticate yourself with your passkey to access your account.",
|
||||
"authenticate": "Authenticate",
|
||||
"please_try_again": "Please try again.",
|
||||
"continue": "Continue",
|
||||
"alternative_sign_in": "Alternative Sign In",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you don't have access to your passkey, you can sign in using one of the following methods.",
|
||||
"use_your_passkey_instead": "Use your passkey instead?",
|
||||
"email_login": "Email Login",
|
||||
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
|
||||
"sign_in_with_login_code": "Sign in with login code",
|
||||
"request_a_login_code_via_email": "Request a login code via email.",
|
||||
"go_back": "Go back",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
|
||||
"enter_code": "Enter code",
|
||||
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
|
||||
"your_email": "Your email",
|
||||
"submit": "Submit",
|
||||
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
|
||||
"code": "Code",
|
||||
"invalid_redirect_url": "Invalid redirect URL",
|
||||
"audit_log": "Audit Log",
|
||||
"users": "Users",
|
||||
"user_groups": "User Groups",
|
||||
"oidc_clients": "OIDC Clients",
|
||||
"api_keys": "API Keys",
|
||||
"application_configuration": "Application Configuration",
|
||||
"settings": "Settings",
|
||||
"update_pocket_id": "Update Pocket ID",
|
||||
"powered_by": "Powered by",
|
||||
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"approximate_location": "Approximate Location",
|
||||
"ip_address": "IP Address",
|
||||
"device": "Device",
|
||||
"client": "Client",
|
||||
"unknown": "Unknown",
|
||||
"account_details_updated_successfully": "Account details updated successfully",
|
||||
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
|
||||
"account_settings": "Account Settings",
|
||||
"passkey_missing": "Passkey missing",
|
||||
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
|
||||
"single_passkey_configured": "Single Passkey Configured",
|
||||
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
|
||||
"account_details": "Account Details",
|
||||
"passkeys": "Passkeys",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
|
||||
"add_passkey": "Add Passkey",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
|
||||
"create": "Create",
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"username": "Username",
|
||||
"save": "Save",
|
||||
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
|
||||
"username_must_start_with": "Username must start with an alphanumeric character",
|
||||
"username_must_end_with": "Username must end with an alphanumeric character",
|
||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
|
||||
"or_visit": "or visit",
|
||||
"added_on": "Added on",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
|
||||
"passkey_deleted_successfully": "Passkey deleted successfully",
|
||||
"delete_passkey_name": "Delete {passkeyName}",
|
||||
"passkey_name_updated_successfully": "Passkey name updated successfully",
|
||||
"name_passkey": "Name Passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
|
||||
"create_api_key": "Create API Key",
|
||||
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Add API Key",
|
||||
"manage_api_keys": "Manage API Keys",
|
||||
"api_key_created": "API Key Created",
|
||||
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
|
||||
"description": "Description",
|
||||
"api_key": "API Key",
|
||||
"close": "Close",
|
||||
"name_to_identify_this_api_key": "Name to identify this API key.",
|
||||
"expires_at": "Expires At",
|
||||
"when_this_api_key_will_expire": "When this API key will expire.",
|
||||
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
|
||||
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
|
||||
"revoke_api_key": "Revoke API Key",
|
||||
"never": "Never",
|
||||
"revoke": "Revoke",
|
||||
"api_key_revoked_successfully": "API key revoked successfully",
|
||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
|
||||
"last_used": "Last Used",
|
||||
"actions": "Actions",
|
||||
"images_updated_successfully": "Images updated successfully. It may take a few minutes to update.",
|
||||
"general": "General",
|
||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
||||
"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": "Configure LDAP settings to sync users and groups from an LDAP server.",
|
||||
"images": "Images",
|
||||
"update": "Update",
|
||||
"email_configuration_updated_successfully": "Email configuration updated successfully",
|
||||
"save_changes_question": "Save changes?",
|
||||
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
|
||||
"save_and_send": "Save and send",
|
||||
"test_email_sent_successfully": "Test email sent successfully to your email address.",
|
||||
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
|
||||
"smtp_configuration": "SMTP Configuration",
|
||||
"smtp_host": "SMTP Host",
|
||||
"smtp_port": "SMTP Port",
|
||||
"smtp_user": "SMTP User",
|
||||
"smtp_password": "SMTP Password",
|
||||
"smtp_from": "SMTP From",
|
||||
"smtp_tls_option": "SMTP TLS Option",
|
||||
"email_tls_option": "Email TLS Option",
|
||||
"skip_certificate_verification": "Skip Certificate Verification",
|
||||
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
|
||||
"enabled_emails": "Enabled Emails",
|
||||
"email_login_notification": "Email Login Notification",
|
||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
|
||||
"emai_login_code_requested_by_user": "Email Login Code Requested by User",
|
||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to bypass passkeys by requesting a login code sent to their email. This significantly reduces security as anyone with access to the user's email can gain entry.",
|
||||
"email_login_code_from_admin": "Email Login Code from Admin",
|
||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
|
||||
"send_test_email": "Send test email",
|
||||
"application_configuration_updated_successfully": "Application configuration updated successfully",
|
||||
"application_name": "Application Name",
|
||||
"session_duration": "Session Duration",
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
|
||||
"enable_self_account_editing": "Enable Self-Account Editing",
|
||||
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
|
||||
"emails_verified": "Emails Verified",
|
||||
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
|
||||
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
|
||||
"ldap_disabled_successfully": "LDAP disabled successfully",
|
||||
"ldap_sync_finished": "LDAP sync finished",
|
||||
"client_configuration": "Client Configuration",
|
||||
"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 Password",
|
||||
"ldap_base_dn": "LDAP Base DN",
|
||||
"user_search_filter": "User Search Filter",
|
||||
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
|
||||
"groups_search_filter": "Groups Search Filter",
|
||||
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
|
||||
"attribute_mapping": "Attribute Mapping",
|
||||
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
|
||||
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
|
||||
"username_attribute": "Username Attribute",
|
||||
"user_mail_attribute": "User Mail Attribute",
|
||||
"user_first_name_attribute": "User First Name Attribute",
|
||||
"user_last_name_attribute": "User Last Name Attribute",
|
||||
"user_profile_picture_attribute": "User Profile Picture Attribute",
|
||||
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
|
||||
"group_members_attribute": "Group Members Attribute",
|
||||
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
|
||||
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
|
||||
"group_rdn_attribute": "Group RDN Attribute (in DN)",
|
||||
"admin_group_name": "Admin Group Name",
|
||||
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
|
||||
"disable": "Disable",
|
||||
"sync_now": "Sync now",
|
||||
"enable": "Enable",
|
||||
"user_created_successfully": "User created successfully",
|
||||
"create_user": "Create User",
|
||||
"add_a_new_user_to_appname": "Add a new user to {appName}",
|
||||
"add_user": "Add User",
|
||||
"manage_users": "Manage Users",
|
||||
"admin_privileges": "Admin Privileges",
|
||||
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
|
||||
"delete_firstname_lastname": "Delete {firstName} {lastName}",
|
||||
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
|
||||
"user_deleted_successfully": "User deleted successfully",
|
||||
"role": "Role",
|
||||
"source": "Source",
|
||||
"admin": "Admin",
|
||||
"user": "User",
|
||||
"local": "Local",
|
||||
"toggle_menu": "Toggle menu",
|
||||
"edit": "Edit",
|
||||
"user_groups_updated_successfully": "User groups updated successfully",
|
||||
"user_updated_successfully": "User updated successfully",
|
||||
"custom_claims_updated_successfully": "Custom claims updated successfully",
|
||||
"back": "Back",
|
||||
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
|
||||
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
|
||||
"custom_claims": "Custom Claims",
|
||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
|
||||
"user_group_created_successfully": "User group created successfully",
|
||||
"create_user_group": "Create User Group",
|
||||
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
|
||||
"add_group": "Add Group",
|
||||
"manage_user_groups": "Manage User Groups",
|
||||
"friendly_name": "Friendly Name",
|
||||
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
|
||||
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
|
||||
"delete_name": "Delete {name}",
|
||||
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
|
||||
"user_group_deleted_successfully": "User group deleted successfully",
|
||||
"user_count": "User Count",
|
||||
"user_group_updated_successfully": "User group updated successfully",
|
||||
"users_updated_successfully": "Users updated successfully",
|
||||
"user_group_details_name": "User Group Details {name}",
|
||||
"assign_users_to_this_group": "Assign users to this group.",
|
||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
|
||||
"oidc_client_created_successfully": "OIDC client created successfully",
|
||||
"create_oidc_client": "Create OIDC Client",
|
||||
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
|
||||
"add_oidc_client": "Add OIDC Client",
|
||||
"manage_oidc_clients": "Manage OIDC Clients",
|
||||
"one_time_link": "One Time Link",
|
||||
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or have lost it.",
|
||||
"add": "Add",
|
||||
"callback_urls": "Callback URLs",
|
||||
"logout_callback_urls": "Logout Callback URLs",
|
||||
"public_client": "Public Client",
|
||||
"public_clients_description": "Public clients do not have a client secret. They are designed for mobile, web, and native applications where secrets cannot be securely stored.",
|
||||
"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 is a security feature to prevent CSRF and authorization code interception attacks.",
|
||||
"requires_reauthentication": "Requires Re-Authentication",
|
||||
"requires_users_to_authenticate_again_on_each_authorization": "Requires users to authenticate again on each authorization, even if already signed in",
|
||||
"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": "Change Logo",
|
||||
"upload_logo": "Upload Logo",
|
||||
"remove_logo": "Remove Logo",
|
||||
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
|
||||
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
|
||||
"authorization_url": "Authorization URL",
|
||||
"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": "Token URL",
|
||||
"userinfo_url": "Userinfo URL",
|
||||
"logout_url": "Logout URL",
|
||||
"certificate_url": "Certificate URL",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"oidc_client_updated_successfully": "OIDC client updated successfully",
|
||||
"create_new_client_secret": "Create new client secret",
|
||||
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
|
||||
"generate": "Generate",
|
||||
"new_client_secret_created_successfully": "New client secret created successfully",
|
||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
||||
"oidc_client_name": "OIDC Client {name}",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client secret",
|
||||
"show_more_details": "Show more details",
|
||||
"allowed_user_groups": "Allowed User Groups",
|
||||
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
|
||||
"favicon": "Favicon",
|
||||
"light_mode_logo": "Light Mode Logo",
|
||||
"dark_mode_logo": "Dark Mode Logo",
|
||||
"background_image": "Background Image",
|
||||
"language": "Language",
|
||||
"reset_profile_picture_question": "Reset profile picture?",
|
||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image and reset the profile picture to default. Do you want to continue?",
|
||||
"reset": "Reset",
|
||||
"reset_to_default": "Reset to default",
|
||||
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
|
||||
"select_the_language_you_want_to_use": "Select the language you want to use. Please note that some text may be automatically translated and could be inaccurate.",
|
||||
"contribute_to_translation": "If you find an issue you're welcome to contribute to the translation on <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||
"personal": "Personal",
|
||||
"global": "Global",
|
||||
"all_users": "All Users",
|
||||
"all_events": "All Events",
|
||||
"all_clients": "All Clients",
|
||||
"all_locations": "All Locations",
|
||||
"global_audit_log": "Global Audit Log",
|
||||
"see_all_account_activities_from_the_last_3_months": "See all user activity for the last 3 months.",
|
||||
"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",
|
||||
"disabled_users_cannot_log_in_or_use_services": "Disabled users cannot log in or use services.",
|
||||
"user_disabled_successfully": "User has been disabled successfully.",
|
||||
"user_enabled_successfully": "User has been enabled successfully.",
|
||||
"status": "Status",
|
||||
"disable_firstname_lastname": "Disable {firstName} {lastName}",
|
||||
"are_you_sure_you_want_to_disable_this_user": "Are you sure you want to disable this user? They will not be able to log in or access any services.",
|
||||
"ldap_soft_delete_users": "Keep disabled users from LDAP.",
|
||||
"ldap_soft_delete_users_description": "When enabled, users removed from LDAP will be disabled rather than deleted from the system.",
|
||||
"login_code_email_success": "The login code has been sent to the user.",
|
||||
"send_email": "Send Email",
|
||||
"show_code": "Show Code",
|
||||
"callback_url_description": "URL(s) provided by your client. Will be automatically added if left blank. Wildcards (*) are supported, but best avoided for better security.",
|
||||
"logout_callback_url_description": "URL(s) provided by your client for logout. Wildcards (*) are supported, but best avoided for better security.",
|
||||
"api_key_expiration": "API Key Expiration",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send an email to the user when their API key is about to expire.",
|
||||
"authorize_device": "Authorize Device",
|
||||
"the_device_has_been_authorized": "The device has been authorized.",
|
||||
"enter_code_displayed_in_previous_step": "Enter the code that was displayed in the previous step.",
|
||||
"authorize": "Authorize",
|
||||
"federated_client_credentials": "Federated Client Credentials",
|
||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "Allowed Group Count",
|
||||
"unrestricted": "Unrestricted",
|
||||
"show_advanced_options": "Show Advanced Options",
|
||||
"hide_advanced_options": "Hide Advanced Options",
|
||||
"oidc_data_preview": "OIDC Data Preview",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Preview the OIDC data that would be sent for different users",
|
||||
"id_token": "ID Token",
|
||||
"access_token": "Access Token",
|
||||
"userinfo": "Userinfo",
|
||||
"id_token_payload": "ID Token Payload",
|
||||
"access_token_payload": "Access Token Payload",
|
||||
"userinfo_endpoint_response": "Userinfo Endpoint Response",
|
||||
"copy": "Copy",
|
||||
"no_preview_data_available": "No preview data available",
|
||||
"copy_all": "Copy All",
|
||||
"preview": "Preview",
|
||||
"preview_for_user": "Preview for {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Preview the OIDC data that would be sent for this user",
|
||||
"show": "Show",
|
||||
"select_an_option": "Select an option",
|
||||
"select_user": "Select User",
|
||||
"error": "Error",
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Select an accent color to customize the appearance of Pocket ID.",
|
||||
"accent_color": "Accent Color",
|
||||
"custom_accent_color": "Custom Accent Color",
|
||||
"custom_accent_color_description": "Enter a custom color using valid CSS color formats (e.g., hex, rgb, hsl).",
|
||||
"color_value": "Color Value",
|
||||
"apply": "Apply",
|
||||
"signup_token": "Signup Token",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Create a signup token to allow new user registration.",
|
||||
"usage_limit": "Usage Limit",
|
||||
"number_of_times_token_can_be_used": "Number of times the signup token can be used.",
|
||||
"expires": "Expires",
|
||||
"signup": "Sign Up",
|
||||
"user_creation": "User Creation",
|
||||
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
|
||||
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
|
||||
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
|
||||
"user_creation_updated_successfully": "User creation settings updated successfully.",
|
||||
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
|
||||
"signup_requires_valid_token": "A valid signup token is required to create an account",
|
||||
"validating_signup_token": "Validating signup token",
|
||||
"go_to_login": "Go to login",
|
||||
"signup_to_appname": "Sign Up to {appName}",
|
||||
"create_your_account_to_get_started": "Create your account to get started.",
|
||||
"initial_account_creation_description": "Please create your account to get started. You will be able to set up a passkey later.",
|
||||
"setup_your_passkey": "Set up your passkey",
|
||||
"create_a_passkey_to_securely_access_your_account": "Create a passkey to securely access your account. This will be your primary way to sign in.",
|
||||
"skip_for_now": "Skip for now",
|
||||
"account_created": "Account Created",
|
||||
"enable_user_signups": "Enable User Signups",
|
||||
"enable_user_signups_description": "Decide how users can sign up for new accounts in Pocket ID.",
|
||||
"user_signups_are_disabled": "User signups are currently disabled",
|
||||
"create_signup_token": "Create Signup Token",
|
||||
"view_active_signup_tokens": "View Active Signup Tokens",
|
||||
"manage_signup_tokens": "Manage Signup Tokens",
|
||||
"view_and_manage_active_signup_tokens": "View and manage active signup tokens.",
|
||||
"signup_token_deleted_successfully": "Signup token deleted successfully.",
|
||||
"expired": "Expired",
|
||||
"used_up": "Used Up",
|
||||
"active": "Active",
|
||||
"usage": "Usage",
|
||||
"created": "Created",
|
||||
"token": "Token",
|
||||
"loading": "Loading",
|
||||
"delete_signup_token": "Delete Signup Token",
|
||||
"are_you_sure_you_want_to_delete_this_signup_token": "Are you sure you want to delete this signup token? This action cannot be undone.",
|
||||
"signup_with_token": "Signup with token",
|
||||
"signup_with_token_description": "Users can only sign up using a valid signup token created by an administrator.",
|
||||
"signup_open": "Open Signup",
|
||||
"signup_open_description": "Anyone can create a new account without restrictions.",
|
||||
"of": "of",
|
||||
"skip_passkey_setup": "Skip Passkey Setup",
|
||||
"skip_passkey_setup_description": "It's highly recommended to set up a passkey because without one, you will be locked out of your account as soon as the session expires.",
|
||||
"my_apps": "My Apps",
|
||||
"no_apps_available": "No apps available",
|
||||
"contact_your_administrator_for_app_access": "Contact your administrator to get access to applications.",
|
||||
"launch": "Launch",
|
||||
"client_launch_url": "Client Launch URL",
|
||||
"client_launch_url_description": "The URL that will be opened when a user launches the app from the My Apps page.",
|
||||
"client_name_description": "The name of the client that shows in the Pocket ID UI.",
|
||||
"revoke_access": "Revoke Access",
|
||||
"revoke_access_description": "Revoke access to <b>{clientName}</b>. <b>{clientName}</b> will no longer be able to access your account information.",
|
||||
"revoke_access_successful": "The access to {clientName} has been successfully revoked.",
|
||||
"last_signed_in_ago": "Last signed in {time} ago",
|
||||
"invalid_client_id": "Client ID can only contain letters, numbers, underscores, and hyphens",
|
||||
"custom_client_id_description": "Set a custom client ID if this is required by your application. Otherwise, leave it blank to generate a random one.",
|
||||
"generated": "Generated",
|
||||
"administration": "Administration",
|
||||
"group_rdn_attribute_description": "The attribute used in the groups distinguished name (DN).",
|
||||
"display_name_attribute": "Display Name Attribute",
|
||||
"display_name": "Display Name",
|
||||
"configure_application_images": "Configure Application Images",
|
||||
"ui_config_disabled_info_title": "UI Configuration Disabled",
|
||||
"ui_config_disabled_info_description": "The UI configuration is disabled because the application configuration settings are managed through environment variables. Some settings may not be editable.",
|
||||
"logo_from_url_description": "Paste a direct image URL (svg, png, webp). Find icons at <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> or <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Invalid URL",
|
||||
"require_user_email": "Require Email Address",
|
||||
"require_user_email_description": "Requires users to have an email address. If disabled, the users without an email address won't be able to use features that require an email address.",
|
||||
"view": "View",
|
||||
"toggle_columns": "Toggle columns",
|
||||
"locale": "Locale",
|
||||
"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": "Re-authentication",
|
||||
"clear_filters": "Clear Filters",
|
||||
"default_profile_picture": "Default Profile Picture",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System"
|
||||
"reauthentication": "Uudelleentodentaminen",
|
||||
"clear_filters": "Tyhjennä suodattimet",
|
||||
"default_profile_picture": "Oletusprofiilikuva",
|
||||
"light": "Vaalea",
|
||||
"dark": "Tumma",
|
||||
"system": "Järjestelmä"
|
||||
}
|
||||
|
||||
@@ -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 ou JPEG.",
|
||||
"image_should_be_in_format": "L'image doit être au format PNG, JPEG ou WEBP.",
|
||||
"items_per_page": "Éléments par page",
|
||||
"no_items_found": "Aucune donnée trouvée",
|
||||
"select_items": "Sélectionner des éléments...",
|
||||
|
||||
@@ -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 o JPEG.",
|
||||
"image_should_be_in_format": "L'immagine deve essere in formato PNG, JPEG o WEBP.",
|
||||
"items_per_page": "Elementi per pagina",
|
||||
"no_items_found": "Nessun elemento trovato",
|
||||
"select_items": "Scegli gli articoli...",
|
||||
|
||||
@@ -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形式である必要があります。",
|
||||
"image_should_be_in_format": "画像はPNG、JPEG、またはWEBP形式である必要があります。",
|
||||
"items_per_page": "ページあたりの表示件数",
|
||||
"no_items_found": "項目が見つかりません",
|
||||
"select_items": "項目を選択…",
|
||||
|
||||
@@ -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 형식이어야 합니다.",
|
||||
"image_should_be_in_format": "이미지는 PNG, JPEG 또는 WEBP 형식이어야 합니다.",
|
||||
"items_per_page": "페이지당 항목",
|
||||
"no_items_found": "항목 없음",
|
||||
"select_items": "항목을 선택하세요...",
|
||||
|
||||
@@ -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- of JPEG-formaat zijn.",
|
||||
"image_should_be_in_format": "De afbeelding moet in PNG-, JPEG- of WEBP-formaat zijn.",
|
||||
"items_per_page": "Aantal per pagina",
|
||||
"no_items_found": "Geen items gevonden",
|
||||
"select_items": "Kies items...",
|
||||
|
||||
@@ -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 lub JPEG.",
|
||||
"image_should_be_in_format": "Obraz powinien być w formacie PNG, JPEG lub WEBP.",
|
||||
"items_per_page": "Elementów na stronę",
|
||||
"no_items_found": "Nie znaleziono żadnych elementów",
|
||||
"select_items": "Wybierz elementy...",
|
||||
|
||||
@@ -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 ou JPEG.",
|
||||
"image_should_be_in_format": "A imagem deve estar no formato PNG, JPEG ou WEBP.",
|
||||
"items_per_page": "Itens por página",
|
||||
"no_items_found": "Nada foi encontrado",
|
||||
"select_items": "Selecione os itens...",
|
||||
|
||||
@@ -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.",
|
||||
"image_should_be_in_format": "Изображение должно быть в формате PNG, JPEG или WEBP.",
|
||||
"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,10 @@
|
||||
"token_sign_in": "Вход с помощью токена",
|
||||
"client_authorization": "Авторизация клиента",
|
||||
"new_client_authorization": "Авторизация нового клиента",
|
||||
"device_code_authorization": "Авторизация кода устройства",
|
||||
"new_device_code_authorization": "Авторизация нового кода устройства",
|
||||
"passkey_added": "Добавлен пароль",
|
||||
"passkey_removed": "Удален ключ доступа",
|
||||
"device_code_authorization": "Авторизация через код устройства",
|
||||
"new_device_code_authorization": "Новая авторизация через код устройства",
|
||||
"passkey_added": "Пасскей добавлен",
|
||||
"passkey_removed": "Пасскей удален",
|
||||
"disable_animations": "Отключить анимации",
|
||||
"turn_off_ui_animations": "Отключить все анимации в интерфейсе.",
|
||||
"user_disabled": "Учетная запись отключена",
|
||||
@@ -467,7 +467,7 @@
|
||||
"reauthentication": "Повторная аутентификация",
|
||||
"clear_filters": "Сбросить фильтры",
|
||||
"default_profile_picture": "Изображение профиля по умолчанию",
|
||||
"light": "Свет",
|
||||
"dark": "Темный",
|
||||
"system": "Система"
|
||||
"light": "Светлая",
|
||||
"dark": "Темная",
|
||||
"system": "Системная"
|
||||
}
|
||||
|
||||
@@ -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- eller JPEG-format.",
|
||||
"image_should_be_in_format": "Bilden ska vara i PNG-, JPEG- eller WEBP-format.",
|
||||
"items_per_page": "Objekt per sida",
|
||||
"no_items_found": "Inga objekt hittades",
|
||||
"select_items": "Välj objekt...",
|
||||
|
||||
@@ -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 veya JPEG formatın’da olmalıdır.",
|
||||
"image_should_be_in_format": "Resim PNG, JPEG veya WEBP formatın’da olmalıdır.",
|
||||
"items_per_page": "Sayfa başına öğe sayısı",
|
||||
"no_items_found": "Hiçbir öğe bulunamadı",
|
||||
"select_items": "Öğeleri seçin...",
|
||||
|
||||
@@ -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.",
|
||||
"image_should_be_in_format": "Зображення повинно бути у форматі PNG, JPEG або WEBP.",
|
||||
"items_per_page": "Елементів на сторінці",
|
||||
"no_items_found": "Нічого не знайдено",
|
||||
"select_items": "Виберіть елементи...",
|
||||
|
||||
@@ -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 hoặc JPEG.",
|
||||
"image_should_be_in_format": "Hình ảnh phải ở định dạng PNG, JPEG hoặc WEBP.",
|
||||
"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...",
|
||||
|
||||
@@ -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 格式。",
|
||||
"image_should_be_in_format": "图片应为 PNG、JPEG 或 WEBP 格式。",
|
||||
"items_per_page": "每页条数",
|
||||
"no_items_found": "这里暂时空空如也",
|
||||
"select_items": "选择项目……",
|
||||
|
||||
@@ -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 格式。",
|
||||
"image_should_be_in_format": "圖片應為 PNG、JPEG 或 WEBP 格式。",
|
||||
"items_per_page": "每頁項目數",
|
||||
"no_items_found": "找不到任何項目",
|
||||
"select_items": "選擇項目...",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pocket-id-frontend",
|
||||
"version": "1.15.0",
|
||||
"version": "1.16.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
@@ -15,49 +15,49 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@simplewebauthn/browser": "^13.2.2",
|
||||
"@tailwindcss/vite": "^4.1.16",
|
||||
"axios": "^1.12.2",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"axios": "^1.13.2",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"jose": "^5.10.0",
|
||||
"jose": "^6.1.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"runed": "^0.31.1",
|
||||
"sveltekit-superforms": "^2.28.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"zod": "^4.1.12"
|
||||
"runed": "^0.37.0",
|
||||
"sveltekit-superforms": "^2.28.1",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"zod": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@inlang/paraglide-js": "^2.4.0",
|
||||
"@inlang/paraglide-js": "^2.5.0",
|
||||
"@inlang/plugin-m-function-matcher": "^2.1.0",
|
||||
"@inlang/plugin-message-format": "^4.0.0",
|
||||
"@internationalized/date": "^3.10.0",
|
||||
"@lucide/svelte": "^0.525.0",
|
||||
"@lucide/svelte": "^0.555.0",
|
||||
"@sveltejs/adapter-static": "^3.0.10",
|
||||
"@sveltejs/kit": "^2.47.3",
|
||||
"@sveltejs/kit": "^2.49.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||
"@types/eslint": "^9.6.1",
|
||||
"@types/node": "^22.18.12",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"bits-ui": "^2.14.1",
|
||||
"eslint": "^9.38.0",
|
||||
"bits-ui": "^2.14.4",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-svelte": "^3.12.5",
|
||||
"eslint-plugin-svelte": "^3.13.0",
|
||||
"formsnap": "^2.0.1",
|
||||
"globals": "^16.4.0",
|
||||
"globals": "^16.5.0",
|
||||
"mode-watcher": "^1.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.7.3",
|
||||
"prettier-plugin-svelte": "^3.4.0",
|
||||
"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",
|
||||
"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",
|
||||
"tslib": "^2.8.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.46.2",
|
||||
"vite": "^7.1.12"
|
||||
"typescript-eslint": "^8.48.0",
|
||||
"vite": "^7.2.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
@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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
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,
|
||||
@@ -61,7 +62,11 @@
|
||||
|
||||
$effect(() => {
|
||||
if (filters) {
|
||||
tableRef?.refresh();
|
||||
filters.userID;
|
||||
filters.event;
|
||||
filters.location;
|
||||
filters.clientName;
|
||||
untrack(() => tableRef?.refresh());
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -39,7 +39,9 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<ModeSwitcher />
|
||||
{#if !isAuthPage}
|
||||
<ModeSwitcher />
|
||||
{/if}
|
||||
{#if $userStore?.id}
|
||||
<HeaderAvatar />
|
||||
{/if}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<script lang="ts">
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
import MoonIcon from '@lucide/svelte/icons/moon';
|
||||
import SunIcon from '@lucide/svelte/icons/sun';
|
||||
|
||||
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 * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { m } from '$lib/paraglide/messages';
|
||||
import { mode, resetMode, setMode } from 'mode-watcher';
|
||||
|
||||
const isDark = $derived(mode.current === 'dark');
|
||||
</script>
|
||||
|
||||
@@ -36,7 +36,10 @@
|
||||
|
||||
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);
|
||||
@@ -45,7 +48,10 @@
|
||||
|
||||
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) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import Root from "./skeleton.svelte";
|
||||
import Root from './skeleton.svelte';
|
||||
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Skeleton,
|
||||
Root as Skeleton
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,9 +12,11 @@ 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;
|
||||
|
||||
@@ -47,7 +47,7 @@ export type AllAppConfig = AppConfig & {
|
||||
ldapAttributeGroupMember: string;
|
||||
ldapAttributeGroupUniqueIdentifier: string;
|
||||
ldapAttributeGroupName: string;
|
||||
ldapAttributeAdminGroup: string;
|
||||
ldapAdminGroupName: string;
|
||||
ldapSoftDeleteUsers: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ export type AuditLog = {
|
||||
};
|
||||
|
||||
export type AuditLogFilter = {
|
||||
userId: string;
|
||||
userID: string;
|
||||
event: string;
|
||||
location: string;
|
||||
clientName: string;
|
||||
|
||||
@@ -9,8 +9,8 @@ export const eventTypes: Record<string, string> = {
|
||||
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(),
|
||||
}
|
||||
PASSKEY_REMOVED: m.passkey_removed()
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates an audit log event type using paraglide messages.
|
||||
|
||||
@@ -22,9 +22,13 @@ 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 = {
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
ldapAttributeGroupMember: z.string().optional(),
|
||||
ldapAttributeGroupUniqueIdentifier: z.string().min(1),
|
||||
ldapAttributeGroupName: z.string().min(1),
|
||||
ldapAttributeAdminGroup: z.string().optional(),
|
||||
ldapAdminGroupName: z.string().optional(),
|
||||
ldapSoftDeleteUsers: z.boolean()
|
||||
});
|
||||
|
||||
@@ -193,7 +193,7 @@
|
||||
label={m.admin_group_name()}
|
||||
description={m.members_of_this_group_will_have_admin_privileges_in_pocketid()}
|
||||
placeholder="_admin_group_name"
|
||||
bind:input={$inputs.ldapAttributeAdminGroup}
|
||||
bind:input={$inputs.ldapAdminGroupName}
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -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>
|
||||
|
||||
4441
pnpm-lock.yaml
generated
4441
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,12 @@
|
||||
packages:
|
||||
- 'frontend'
|
||||
- 'tests'
|
||||
- 'email-templates'
|
||||
- frontend
|
||||
- tests
|
||||
- email-templates
|
||||
|
||||
overrides:
|
||||
'devalue': '^5.3.2'
|
||||
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'
|
||||
|
||||
@@ -14,19 +14,16 @@ PGID=${PGID:-1000}
|
||||
|
||||
# Check if the group with PGID exists; if not, create it
|
||||
if ! getent group pocket-id-group > /dev/null 2>&1; then
|
||||
echo "Creating group $PGID..."
|
||||
addgroup -g "$PGID" pocket-id-group
|
||||
fi
|
||||
|
||||
# Check if a user with PUID exists; if not, create it
|
||||
if ! id -u pocket-id > /dev/null 2>&1; then
|
||||
if ! getent passwd "$PUID" > /dev/null 2>&1; then
|
||||
echo "Creating user $PUID..."
|
||||
adduser -u "$PUID" -G pocket-id-group pocket-id > /dev/null 2>&1
|
||||
adduser -uD "$PUID" -G pocket-id-group pocket-id > /dev/null 2>&1
|
||||
else
|
||||
# If a user with the PUID already exists, use that user
|
||||
existing_user=$(getent passwd "$PUID" | cut -d: -f1)
|
||||
echo "Using existing user: $existing_user"
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
@@ -7,10 +7,14 @@
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.56.1",
|
||||
"@playwright/test": "^1.57.0",
|
||||
"@types/adm-zip": "^0.5.7",
|
||||
"@types/node": "^22.18.12",
|
||||
"dotenv": "^16.6.1",
|
||||
"jose": "^6.1.0",
|
||||
"prettier": "^3.6.2"
|
||||
"dotenv": "^17.2.3",
|
||||
"jose": "^6.1.2",
|
||||
"prettier": "^3.7.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"adm-zip": "^0.5.16"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,11 +21,15 @@ export default defineConfig({
|
||||
trace: 'on-first-retry'
|
||||
},
|
||||
projects: [
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
{ name: 'cli', testMatch: /cli\.spec\.ts/ },
|
||||
{ name: 'auth-setup', testMatch: /auth\.setup\.ts/ },
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'], storageState: '.auth/user.json' },
|
||||
dependencies: ['setup']
|
||||
name: 'browser-chrome',
|
||||
use: { ...devices['Desktop Chrome'], storageState: '.tmp/auth/user.json' },
|
||||
testIgnore: /cli\.spec\.ts/,
|
||||
dependencies: ['auth-setup']
|
||||
}
|
||||
]
|
||||
],
|
||||
globalSetup: './specs/fixtures/global.setup.ts',
|
||||
globalTeardown: './specs/fixtures/global.teardown.ts'
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user