mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-20 21:36:54 +03:00
Compare commits
65 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d40bf29ab | ||
|
|
ff26c4273a | ||
|
|
444f7ff2b0 | ||
|
|
a0cb574313 | ||
|
|
978ac87def | ||
|
|
59fe481af9 | ||
|
|
4f09de2cfc | ||
|
|
8f48d10d55 | ||
|
|
5c4d7ff877 | ||
|
|
c5a4ffa523 | ||
|
|
6449b28b24 | ||
|
|
90fbfd7038 | ||
|
|
9ec4683d18 | ||
|
|
027e6f078d | ||
|
|
33cceeafa8 | ||
|
|
544f4e63d8 | ||
|
|
86152d996c | ||
|
|
fbdb93f1a7 | ||
|
|
f8f7222468 | ||
|
|
f79a86cded | ||
|
|
626adbf14c | ||
|
|
2b94535ade | ||
|
|
e825a58b39 | ||
|
|
b85a81f9b1 | ||
|
|
a06d9d21e4 | ||
|
|
cbecbd088f | ||
|
|
3c42a713ce | ||
|
|
e7e0176316 | ||
|
|
0551502586 | ||
|
|
5251cd9799 | ||
|
|
673e5841aa | ||
|
|
dc6558522e | ||
|
|
724c41cb7a | ||
|
|
fc52bd4efb | ||
|
|
2701754e73 | ||
|
|
3700bd942d | ||
|
|
2b5401dd2f | ||
|
|
95e9af4bbf | ||
|
|
0c039cc88c | ||
|
|
192f71a13c | ||
|
|
f90f21b620 | ||
|
|
d71966f996 | ||
|
|
cad80e7d74 | ||
|
|
832b7fbff4 | ||
|
|
e3905cf315 | ||
|
|
b59e35cb59 | ||
|
|
a675d075d1 | ||
|
|
2f56d16f98 | ||
|
|
f4eb8db509 | ||
|
|
e7bd66d1a7 | ||
|
|
1d06817065 | ||
|
|
34890235ba | ||
|
|
27ca713cd4 | ||
|
|
e0fc4cc01b | ||
|
|
01141b8c0f | ||
|
|
8fecc22888 | ||
|
|
d7f19ad5e5 | ||
|
|
45bcdb4b1d | ||
|
|
89349dc1ad | ||
|
|
6159e0bf96 | ||
|
|
4d22c2dbcf | ||
|
|
590e495c1d | ||
|
|
3a339e3319 | ||
|
|
d98db79d5e | ||
|
|
7d2a9b3345 |
106
.github/workflows/pr-quality.yml
vendored
Normal file
106
.github/workflows/pr-quality.yml
vendored
Normal file
@@ -0,0 +1,106 @@
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
pr-quality:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
# General Settings
|
||||
max-failures: 4
|
||||
|
||||
# PR Branch Checks
|
||||
allowed-target-branches: "main"
|
||||
blocked-target-branches: ""
|
||||
allowed-source-branches: ""
|
||||
blocked-source-branches: ""
|
||||
|
||||
# PR Quality Checks
|
||||
max-negative-reactions: 0
|
||||
require-maintainer-can-modify: true
|
||||
|
||||
# PR Title Checks
|
||||
require-conventional-title: true
|
||||
|
||||
# PR Description Checks
|
||||
require-description: true
|
||||
max-description-length: 2500
|
||||
max-emoji-count: 0
|
||||
max-code-references: 0
|
||||
require-linked-issue: false
|
||||
blocked-terms: ""
|
||||
blocked-issue-numbers: ""
|
||||
|
||||
# PR Template Checks
|
||||
require-pr-template: true
|
||||
strict-pr-template-sections: ""
|
||||
optional-pr-template-sections: "Issues"
|
||||
max-additional-pr-template-sections: 3
|
||||
|
||||
# Commit Message Checks
|
||||
max-commit-message-length: 500
|
||||
require-conventional-commits: false
|
||||
require-commit-author-match: true
|
||||
blocked-commit-authors: ""
|
||||
|
||||
# File Checks
|
||||
allowed-file-extensions: ""
|
||||
allowed-paths: ""
|
||||
blocked-paths: |
|
||||
SECURITY.md
|
||||
LICENSE
|
||||
require-final-newline: false
|
||||
max-added-comments: 0
|
||||
|
||||
# User Checks
|
||||
detect-spam-usernames: true
|
||||
min-account-age: 30
|
||||
max-daily-forks: 7
|
||||
min-profile-completeness: 4
|
||||
|
||||
# Merge Checks
|
||||
min-repo-merged-prs: 0
|
||||
min-repo-merge-ratio: 0
|
||||
min-global-merge-ratio: 30
|
||||
global-merge-ratio-exclude-own: false
|
||||
|
||||
# Exemptions
|
||||
exempt-draft-prs: false
|
||||
exempt-bots: |
|
||||
actions-user
|
||||
dependabot[bot]
|
||||
renovate[bot]
|
||||
github-actions[bot]
|
||||
exempt-users: ""
|
||||
exempt-author-association: "OWNER,MEMBER,COLLABORATOR"
|
||||
exempt-label: "quality/exempt"
|
||||
exempt-pr-label: ""
|
||||
exempt-all-milestones: false
|
||||
exempt-all-pr-milestones: false
|
||||
exempt-milestones: ""
|
||||
exempt-pr-milestones: ""
|
||||
|
||||
# PR Success Actions
|
||||
success-add-pr-labels: "quality/verified"
|
||||
|
||||
# PR Failure Actions
|
||||
failure-remove-pr-labels: ""
|
||||
failure-remove-all-pr-labels: true
|
||||
failure-add-pr-labels: "quality/rejected"
|
||||
failure-pr-message: |
|
||||
This PR did not pass quality checks so it will be closed.
|
||||
See the [workflow run](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}/attempts/${{ github.run_attempt }}) for details on which checks failed.
|
||||
|
||||
If you believe this is a mistake please let us know.
|
||||
|
||||
close-pr: true
|
||||
lock-pr: false
|
||||
96
CHANGELOG.md
96
CHANGELOG.md
@@ -1,3 +1,99 @@
|
||||
## v2.6.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- disable callback URLs with protocols "javascript" and "data" ([#1397](https://github.com/pocket-id/pocket-id/pull/1397) by @ItalyPaleAle)
|
||||
- strip Root prefix from S3 List() returned paths ([#1413](https://github.com/pocket-id/pocket-id/pull/1413) by @vtmocanu)
|
||||
- use valid Tailwind v4 transition class for auth animation squares ([#1415](https://github.com/pocket-id/pocket-id/pull/1415) by @CoolShades)
|
||||
- resolve posixGroup memberUid as bare usernames ([#1422](https://github.com/pocket-id/pocket-id/pull/1422) by @gucong3000)
|
||||
- prevent flickering if no background image is set on login page ([027e6f0](https://github.com/pocket-id/pocket-id/commit/027e6f078da0eec712ae22a04b37c86110cb262b) by @stonith404)
|
||||
- improve form input layout if description next to it is multi col ([9ec4683](https://github.com/pocket-id/pocket-id/commit/9ec4683d18036ba1945bffd4bce14ec4c2dff7f9) by @stonith404)
|
||||
- access token renewal bypasses important checks ([978ac87](https://github.com/pocket-id/pocket-id/commit/978ac87deffec58beaccd15aead975e91b94c8a5) by @stonith404)
|
||||
|
||||
### Features
|
||||
|
||||
- add ability to revoke passkeys of users as admin ([#1386](https://github.com/pocket-id/pocket-id/pull/1386) by @jose-d)
|
||||
- add auth method claim (`amr`) to tokens ([#1433](https://github.com/pocket-id/pocket-id/pull/1433) by @stonith404)
|
||||
- add TLS support for HTTP/2 server ([#1429](https://github.com/pocket-id/pocket-id/pull/1429) by @IngmarStein)
|
||||
- add OpenID Connect `prompt` Parameter Handling ([#1299](https://github.com/pocket-id/pocket-id/pull/1299) by @rjaakke)
|
||||
- return not found. on `/setup` if already completed ([444f7ff](https://github.com/pocket-id/pocket-id/commit/444f7ff2b0269c12f1dba334a37d7db2007e172f) by @stonith404)
|
||||
|
||||
### Other
|
||||
|
||||
- update AAGUIDs ([#1403](https://github.com/pocket-id/pocket-id/pull/1403) by @github-actions[bot])
|
||||
- upgrade dependencies ([f8f7222](https://github.com/pocket-id/pocket-id/commit/f8f7222468dad90f630ae18f7c3fd78e37ba3f77) by @stonith404)
|
||||
- combobox not closed in e2e test ([fbdb93f](https://github.com/pocket-id/pocket-id/commit/fbdb93f1a768a05e6e3f2c6fd32b5de50a745bc6) by @stonith404)
|
||||
- Security upgrade alpine from latest to 3.23.4 ([#1431](https://github.com/pocket-id/pocket-id/pull/1431) by @stonith404)
|
||||
- security upgrade alpine from latest to 3.23.4 ([#1432](https://github.com/pocket-id/pocket-id/pull/1432) by @stonith404)
|
||||
- add Catalan language files ([4f09de2](https://github.com/pocket-id/pocket-id/commit/4f09de2cfc7d1e92632116821493a670fc7ee80d) by @stonith404)
|
||||
- reduce complexity of `ValidateEnvConfig` and `initRouter` ([a0cb574](https://github.com/pocket-id/pocket-id/commit/a0cb57431372c2bcc59904342597845e92a42a93) by @stonith404)
|
||||
- pass context to `shutdownServer` ([ff26c42](https://github.com/pocket-id/pocket-id/commit/ff26c4273a061b7d2c84e7b74f1e0f9e0acc6eb0) by @stonith404)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.5.0...v2.6.0
|
||||
|
||||
## v2.5.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- better error messages when there's another instance of Pocket ID running ([#1370](https://github.com/pocket-id/pocket-id/pull/1370) by @ItalyPaleAle)
|
||||
- move tooltip inside of form input to prevent shifting ([#1369](https://github.com/pocket-id/pocket-id/pull/1369) by @GameTec-live)
|
||||
- derive LDAP admin access from group membership ([#1374](https://github.com/pocket-id/pocket-id/pull/1374) by @kmendell)
|
||||
- avoid fmt.Sprintf on custom GeoLiteDBUrl without %s placeholder ([#1384](https://github.com/pocket-id/pocket-id/pull/1384) by @choyri)
|
||||
- show a warning when SQLite DB is stored on NFS/SMB/FUSE ([#1381](https://github.com/pocket-id/pocket-id/pull/1381) by @ItalyPaleAle)
|
||||
- empty background restore after reboot ([#1379](https://github.com/pocket-id/pocket-id/pull/1379) by @taoso)
|
||||
- allow one-char username on signup ([#1378](https://github.com/pocket-id/pocket-id/pull/1378) by @taoso)
|
||||
|
||||
### Features
|
||||
|
||||
- allow use of svg, png, and ico images types for favicon ([#1289](https://github.com/pocket-id/pocket-id/pull/1289) by @taoso)
|
||||
- allow clearing background image ([#1290](https://github.com/pocket-id/pocket-id/pull/1290) by @taoso)
|
||||
- add `token_endpoint_auth_methods_supported` to `.well-known` ([#1388](https://github.com/pocket-id/pocket-id/pull/1388) by @owenvoke)
|
||||
- add TRUSTED_PLATFORM environment variable for gin ([#1372](https://github.com/pocket-id/pocket-id/pull/1372) by @choyri)
|
||||
|
||||
### Other
|
||||
|
||||
- add pr quality action ([e3905cf](https://github.com/pocket-id/pocket-id/commit/e3905cf3159fe0370778b0d7d3be64b4246d19be) by @stonith404)
|
||||
- separate querying LDAP and updating DB during sync ([#1371](https://github.com/pocket-id/pocket-id/pull/1371) by @ItalyPaleAle)
|
||||
- bump google.golang.org/grpc from 1.79.1 to 1.79.3 in /backend in the go_modules group across 1 directory ([#1391](https://github.com/pocket-id/pocket-id/pull/1391) by @dependabot[bot])
|
||||
- Improve Latvian translations in lv.json ([#1382](https://github.com/pocket-id/pocket-id/pull/1382) by @Raito00)
|
||||
- ignore linter on app image bootstrap ([5251cd9](https://github.com/pocket-id/pocket-id/commit/5251cd97994177c96cb6f9ab3f88ca31367b5b55) by @kmendell)
|
||||
- upgrade dependencies ([e7e0176](https://github.com/pocket-id/pocket-id/commit/e7e0176316857186b9683e2f0cb0686189f86cfb) by @kmendell)
|
||||
- upgrade dependencies ([3c42a71](https://github.com/pocket-id/pocket-id/commit/3c42a713ce91b4061ffcf86d92cbb19294359ff8) by @kmendell)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.4.0...v2.5.0
|
||||
|
||||
## v2.4.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- improve wildcard matching by using `go-urlpattern` ([#1332](https://github.com/pocket-id/pocket-id/pull/1332) by @stonith404)
|
||||
- federated client credentials not working if sub ≠ client_id ([#1342](https://github.com/pocket-id/pocket-id/pull/1342) by @ItalyPaleAle)
|
||||
- handle IPv6 addresses in callback URLs ([#1355](https://github.com/pocket-id/pocket-id/pull/1355) by @ItalyPaleAle)
|
||||
- wildcard callback URLs blocked by browser-native URL validation ([#1359](https://github.com/pocket-id/pocket-id/pull/1359) by @Copilot)
|
||||
- one-time-access-token route should get user ID from URL only ([#1358](https://github.com/pocket-id/pocket-id/pull/1358) by @ItalyPaleAle)
|
||||
- various fixes in background jobs ([#1362](https://github.com/pocket-id/pocket-id/pull/1362) by @ItalyPaleAle)
|
||||
- use URL keyboard type for callback URL inputs ([a675d07](https://github.com/pocket-id/pocket-id/commit/a675d075d1ab9b7ff8160f1cfc35bc0ea1f1980a) by @stonith404)
|
||||
|
||||
### Features
|
||||
|
||||
- allow first name and display name to be optional ([#1288](https://github.com/pocket-id/pocket-id/pull/1288) by @taoso)
|
||||
|
||||
### Other
|
||||
|
||||
- bump svelte from 5.53.2 to 5.53.5 in the npm_and_yarn group across 1 directory ([#1348](https://github.com/pocket-id/pocket-id/pull/1348) by @dependabot[bot])
|
||||
- bump @sveltejs/kit from 2.53.0 to 2.53.3 in the npm_and_yarn group across 1 directory ([#1349](https://github.com/pocket-id/pocket-id/pull/1349) by @dependabot[bot])
|
||||
- update AAGUIDs ([#1354](https://github.com/pocket-id/pocket-id/pull/1354) by @github-actions[bot])
|
||||
- add Português files ([01141b8](https://github.com/pocket-id/pocket-id/commit/01141b8c0f2e96a40fd876d3206e49a694fd12c4) by @kmendell)
|
||||
- add Latvian files ([e0fc4cc](https://github.com/pocket-id/pocket-id/commit/e0fc4cc01bd51e5a97e46aad78a493a668049220) by @kmendell)
|
||||
- fix wrong seed data ([e7bd66d](https://github.com/pocket-id/pocket-id/commit/e7bd66d1a77c89dde542b4385ba01dc0d432e434) by @stonith404)
|
||||
- fix wrong seed data in `database.json` ([f4eb8db](https://github.com/pocket-id/pocket-id/commit/f4eb8db50993edacd90e919b39a5c6d9dd4924c7) by @stonith404)
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
- frontend performance optimizations ([#1344](https://github.com/pocket-id/pocket-id/pull/1344) by @ItalyPaleAle)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.3.0...v2.4.0
|
||||
|
||||
## v2.3.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -58,9 +60,16 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||
}
|
||||
|
||||
cacheMaxAge := time.Hour * 24
|
||||
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
||||
// Load a map of all files to see which ones are available pre-compressed
|
||||
preCompressed, err := listPreCompressedAssets(distFS)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
|
||||
}
|
||||
|
||||
// Init the file server
|
||||
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
|
||||
|
||||
// Handler for Gin
|
||||
handler := func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
|
||||
@@ -108,34 +117,138 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
|
||||
type FileServerWithCaching struct {
|
||||
root http.FileSystem
|
||||
lastModified time.Time
|
||||
cacheMaxAge int
|
||||
lastModifiedHeaderValue string
|
||||
cacheControlHeaderValue string
|
||||
preCompressed preCompressedMap
|
||||
}
|
||||
|
||||
func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
|
||||
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching {
|
||||
return &FileServerWithCaching{
|
||||
root: root,
|
||||
lastModified: time.Now(),
|
||||
cacheMaxAge: maxAge,
|
||||
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
|
||||
cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
|
||||
preCompressed: preCompressed,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Check if the client has a cached version
|
||||
if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
|
||||
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
||||
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
|
||||
// Client's cached version is up to date
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
// First, set cache headers
|
||||
// Check if the request is for an immutable asset
|
||||
if isImmutableAsset(r) {
|
||||
// Set the cache control header as immutable with a long expiration
|
||||
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
|
||||
} else {
|
||||
// Check if the client has a cached version
|
||||
ifModifiedSince := r.Header.Get("If-Modified-Since")
|
||||
if ifModifiedSince != "" {
|
||||
ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
|
||||
if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
|
||||
// Client's cached version is up to date
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Cache other assets for up to 24 hours, but set Last-Modified too
|
||||
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
}
|
||||
|
||||
// Check if the asset is available pre-compressed
|
||||
_, ok := f.preCompressed[r.URL.Path]
|
||||
if ok {
|
||||
// Add a "Vary" with "Accept-Encoding" so CDNs are aware that content is pre-compressed
|
||||
w.Header().Add("Vary", "Accept-Encoding")
|
||||
|
||||
// Select the encoding if any
|
||||
ext, ce := f.selectEncoding(r)
|
||||
if ext != "" {
|
||||
// Set the content type explicitly before changing the path
|
||||
ct := mime.TypeByExtension(path.Ext(r.URL.Path))
|
||||
if ct != "" {
|
||||
w.Header().Set("Content-Type", ct)
|
||||
}
|
||||
|
||||
// Make the serve return the encoded content
|
||||
w.Header().Set("Content-Encoding", ce)
|
||||
r.URL.Path += "." + ext
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Last-Modified", f.lastModifiedHeaderValue)
|
||||
w.Header().Set("Cache-Control", f.cacheControlHeaderValue)
|
||||
|
||||
http.FileServer(f.root).ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
func (f *FileServerWithCaching) selectEncoding(r *http.Request) (ext string, contentEnc string) {
|
||||
available, ok := f.preCompressed[r.URL.Path]
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Check if the client accepts compressed files
|
||||
acceptEncoding := strings.TrimSpace(strings.ToLower(r.Header.Get("Accept-Encoding")))
|
||||
if acceptEncoding == "" {
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// Prefer brotli over gzip when both are accepted.
|
||||
if available.br && (acceptEncoding == "*" || acceptEncoding == "br" || strings.Contains(acceptEncoding, "br")) {
|
||||
return "br", "br"
|
||||
}
|
||||
if available.gz && (acceptEncoding == "gzip" || strings.Contains(acceptEncoding, "gzip")) {
|
||||
return "gz", "gzip"
|
||||
}
|
||||
|
||||
return "", ""
|
||||
}
|
||||
|
||||
func isImmutableAsset(r *http.Request) bool {
|
||||
switch {
|
||||
// Fonts
|
||||
case strings.HasPrefix(r.URL.Path, "/fonts/"):
|
||||
return true
|
||||
|
||||
// Compiled SvelteKit assets
|
||||
case strings.HasPrefix(r.URL.Path, "/_app/immutable/"):
|
||||
return true
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
type preCompressedMap map[string]struct {
|
||||
br bool
|
||||
gz bool
|
||||
}
|
||||
|
||||
func listPreCompressedAssets(distFS fs.FS) (preCompressedMap, error) {
|
||||
preCompressed := make(preCompressedMap, 0)
|
||||
err := fs.WalkDir(distFS, ".", func(path string, d fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
|
||||
if d.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(path, ".br"):
|
||||
originalPath := "/" + strings.TrimSuffix(path, ".br")
|
||||
entry := preCompressed[originalPath]
|
||||
entry.br = true
|
||||
preCompressed[originalPath] = entry
|
||||
case strings.HasSuffix(path, ".gz"):
|
||||
originalPath := "/" + strings.TrimSuffix(path, ".gz")
|
||||
entry := preCompressed[originalPath]
|
||||
entry.gz = true
|
||||
preCompressed[originalPath] = entry
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return preCompressed, nil
|
||||
}
|
||||
|
||||
169
backend/go.mod
169
backend/go.mod
@@ -3,98 +3,101 @@ module github.com/pocket-id/pocket-id/backend
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.9
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.9
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/aws/smithy-go v1.24.1
|
||||
github.com/caarlos0/env/v11 v11.3.1
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0
|
||||
github.com/aws/smithy-go v1.24.3
|
||||
github.com/caarlos0/env/v11 v11.4.0
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
|
||||
github.com/disintegration/imaging v1.6.2
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6
|
||||
github.com/emersion/go-smtp v0.24.0
|
||||
github.com/gin-contrib/slog v1.2.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/gin-contrib/slog v1.2.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/glebarez/go-sqlite v1.22.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.19.1
|
||||
github.com/go-ldap/ldap/v3 v3.4.12
|
||||
github.com/go-playground/validator/v10 v10.30.1
|
||||
github.com/go-webauthn/webauthn v0.15.0
|
||||
github.com/go-co-op/gocron/v2 v2.20.0
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.2
|
||||
github.com/go-webauthn/webauthn v0.16.4
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/hashicorp/go-uuid v1.0.3
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-isatty v0.0.20
|
||||
github.com/mattn/go-isatty v0.0.21
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0
|
||||
go.opentelemetry.io/otel v1.40.0
|
||||
go.opentelemetry.io/otel/log v0.16.0
|
||||
go.opentelemetry.io/otel/metric v1.40.0
|
||||
go.opentelemetry.io/otel/sdk v1.40.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0
|
||||
go.opentelemetry.io/otel/trace v1.40.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
golang.org/x/image v0.36.0
|
||||
golang.org/x/net v0.50.0
|
||||
golang.org/x/sync v0.19.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/log v0.19.0
|
||||
go.opentelemetry.io/otel/metric v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.50.0
|
||||
golang.org/x/image v0.39.0
|
||||
golang.org/x/net v0.53.0
|
||||
golang.org/x/sync v0.20.0
|
||||
golang.org/x/text v0.36.0
|
||||
golang.org/x/time v0.15.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 // 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.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.24.4 // indirect
|
||||
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.1 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 // indirect
|
||||
github.com/disintegration/gift v1.2.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/fsnotify/fsnotify v1.8.0 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/go-webauthn/x v0.2.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/go-webauthn/x v0.2.3 // indirect
|
||||
github.com/goccy/go-json v0.10.6 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect
|
||||
github.com/google/go-github/v39 v39.2.0 // indirect
|
||||
@@ -104,7 +107,7 @@ require (
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.8.0 // indirect
|
||||
github.com/jackc/pgx/v5 v5.9.1 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
@@ -113,59 +116,63 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 // indirect
|
||||
github.com/lestrrat-go/dsig v1.0.0 // indirect
|
||||
github.com/lestrrat-go/dsig v1.2.1 // indirect
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
|
||||
github.com/lestrrat-go/httpcc v1.0.1 // indirect
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 // indirect
|
||||
github.com/lib/pq v1.11.2 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||
github.com/lib/pq v1.12.3 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.42 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/nlnwa/whatwg-url v0.6.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
|
||||
github.com/philhofer/fwd v1.2.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // 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.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/prometheus/procfs v0.20.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.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/tinylib/msgp v1.6.3 // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.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.24.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect
|
||||
golang.org/x/oauth2 v0.35.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect
|
||||
google.golang.org/grpc v1.79.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.4 // indirect
|
||||
golang.org/x/arch v0.26.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/libc v1.71.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
modernc.org/sqlite v1.48.2 // indirect
|
||||
)
|
||||
|
||||
429
backend/go.sum
429
backend/go.sum
@@ -6,54 +6,57 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
|
||||
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM=
|
||||
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.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 h1:RuNSMoozM8oXlgLG/n6WLaFGoea7/CddrCfIiSA+xdY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17/go.mod h1:F2xxQ9TZz5gDWsclCtPQscGpP0VUOc8RqgFM3vDENmU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 h1:bGeHBsGZx0Dvu/eJC0Lh9adJa3M1xREcndxLNZlve2U=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIMmILM+RraSyB8KA=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ=
|
||||
github.com/aws/smithy-go v1.24.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0=
|
||||
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5 h1:dj5kopbwUsVUVFgO4Fi5BIT3t4WyqIDjGKCangnV/yY=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.5/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 h1:eBMB84YGghSocM7PsjmmPffTa+1FBUeNvGvFou6V/4o=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8/go.mod h1:lyw7GFp3qENLh7kwzf7iMzAxDn+NzjXEAGjKS2UOKqI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.14/go.mod h1:U4/V0uKxh0Tl5sxmCBZ3AecYny4UNlVmObYjKuuaiOo=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8THYELoX6gVcUvgl6fI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21 h1:Rgg6wvjjtX8bNHcvi9OnXWwcE0a2vGpbwmtICOsvcf4=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.21/go.mod h1:A/kJFst/nm//cyqonihbdpQZwiUhhzpqTsdbhDdRF9c=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21 h1:PEgGVtPoB6NTpPrBgqSE5hE/o47Ij9qk/SEZFbUOe9A=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.21/go.mod h1:p+hz+PRAYlY3zcpJhPwXlLC4C+kqn70WIHwnzAfs6ps=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw=
|
||||
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22 h1:rWyie/PxDRIdhNf4DzRk0lvjVOqFJuNnO8WwaIRVxzQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.22/go.mod h1:zd/JsJ4P7oGfUhXn1VyLqaRZwPmZwg44Jf2dS84Dm3Y=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13 h1:JRaIgADQS/U6uXDqlPiefP32yXTda7Kqfx+LgspooZM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.13/go.mod h1:CEuVn5WqOMilYl+tbccq8+N2ieCy0gVn3OtRb0vBNNM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21 h1:c31//R3xgIJMSC8S6hEVq+38DcvUlgFY0FM6mSI5oto=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.21/go.mod h1:r6+pf23ouCB718FUxaqzZdbpYFyDtehyZcmP5KL9FkA=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21 h1:ZlvrNcHSFFWURB8avufQq9gFsheUgjVD9536obIknfM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.21/go.mod h1:cv3TNhVrssKR0O/xxLJVRfd2oazSnZnkUeTf6ctUwfQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0 h1:hlSuz394kV0vhv9drL5lhuEFbEOEP1VyQpy15qWh1Pk=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.99.0/go.mod h1:uoA43SdFwacedBfSgfFSjjCvYe8aYBS7EnU5GZ/YKMM=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9 h1:QKZH0S178gCmFEgst8hN0mCX1KxLgHBKKY/CLqwP8lg=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.9/go.mod h1:7yuQJoT+OoH8aqIxw9vwF+8KpvLZ8AWmvmUWHsGQZvI=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15 h1:lFd1+ZSEYJZYvv9d6kXzhkZu07si3f+GQ1AaYwa2LUM=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.15/go.mod h1:WSvS1NLr7JaPunCXqpJnWk1Bjo7IxzZXrZi1QQCkuqM=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6fuOwWlWpD2StNLTceKpys=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw=
|
||||
github.com/aws/smithy-go v1.24.3 h1:XgOAaUgx+HhVBoP4v8n6HCQoTRDhoMghKqw4LNHsDNg=
|
||||
github.com/aws/smithy-go v1.24.3/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||
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/bits-and-blooms/bitset v1.20.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.24.4 h1:95H15Og1clikBrKr/DuzMXkQzECs1M6hhoGXLwLQOZE=
|
||||
github.com/bits-and-blooms/bitset v1.24.4/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.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/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
|
||||
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/caarlos0/env/v11 v11.4.0 h1:Kcb6t5kIIr4XkoQC9AF2j+8E1Jsrl3Wz/hhm1LtoGAc=
|
||||
github.com/caarlos0/env/v11 v11.4.0/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
@@ -69,8 +72,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 h1:NMZiJj8QnKe1LgsbDayM4UoHwbvwDRwnI3hwNaAHRnc=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1 h1:5RVFMOWjMyRy8cARdy79nAmgYw3hK/4HUq48LQ6Wwqo=
|
||||
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.1/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40=
|
||||
github.com/dhui/dktest v0.4.6 h1:+DPKyScKSEp3VLtbMDHcUq6V5Lm5zfZZVb0Sk7Ahom4=
|
||||
github.com/dhui/dktest v0.4.6/go.mod h1:JHTSYDtKkvFNFHJKqCzVzqXecyv+tKt8EzceOmQOgbU=
|
||||
github.com/disintegration/gift v1.1.2/go.mod h1:Jh2i7f7Q2BM7Ezno3PhfezbR1xpUg9dUg3/RlKGr4HI=
|
||||
@@ -88,6 +91,8 @@ github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj
|
||||
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
|
||||
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
|
||||
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s=
|
||||
github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
|
||||
@@ -96,26 +101,28 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6
|
||||
github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
|
||||
github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1 h1:2rWm8B193Ll4VdjsJY28jxs70IdDsHRWgQYAI80+rMQ=
|
||||
github.com/fxamacker/cbor/v2 v2.9.1/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=
|
||||
github.com/gabriel-vasile/mimetype v1.4.13/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=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/gin-contrib/slog v1.2.1 h1:tQbsmllW/PNgtvHRdVlI38jLfpN4IFLS7Pb4HgTeiYw=
|
||||
github.com/gin-contrib/slog v1.2.1/go.mod h1:f/Ke0A3h4DUh0cQnjR2b/l+i0EmVJ+6VY6GIw3RKtxA=
|
||||
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ=
|
||||
github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI=
|
||||
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
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-co-op/gocron/v2 v2.20.0 h1:9IMrnnVSWjfSh3E54gWmWCHbloQJLh6f9+nwyKfLNpc=
|
||||
github.com/go-co-op/gocron/v2 v2.20.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ=
|
||||
github.com/go-ldap/ldap/v3 v3.4.13/go.mod h1:LxsGZV6vbaK0sIvYfsv47rfh4ca0JXokCoKjZxsszv0=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
@@ -127,16 +134,16 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ=
|
||||
github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||
github.com/go-viper/mapstructure/v2 v2.5.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.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc=
|
||||
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4=
|
||||
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/go-webauthn/webauthn v0.16.4 h1:R9jqR/cYZa7hRquFF7Za/8qoH/K/TIs1/Q/4CyGN+1Q=
|
||||
github.com/go-webauthn/webauthn v0.16.4/go.mod h1:SU2ljAgToTV/YLPI0C05QS4qn+e04WpB5g1RMfcZfS4=
|
||||
github.com/go-webauthn/x v0.2.3 h1:8oArS+Rc1SWFLXhE17KZNx258Z4kUSyaDgsSncCO5RA=
|
||||
github.com/go-webauthn/x v0.2.3/go.mod h1:tM04GF3V6VYq79AZMl7vbj4q6pz9r7L2criWRzbWhPk=
|
||||
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
@@ -161,6 +168,8 @@ github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfh
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
|
||||
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
|
||||
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
|
||||
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=
|
||||
@@ -178,8 +187,8 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc=
|
||||
github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
|
||||
@@ -220,26 +229,26 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4 h1:IwQibdnf8l2KoO+qC3uT4OaTWsW7tuRQXy9TRN9QanA=
|
||||
github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw=
|
||||
github.com/lestrrat-go/dsig v1.0.0 h1:OE09s2r9Z81kxzJYRn07TFM9XA4akrUdoMwr0L8xj38=
|
||||
github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo=
|
||||
github.com/lestrrat-go/dsig v1.2.1 h1:MwxzZhE4+4fguHi+uDALKVlC3Cn+O1QU1Q/F8D7hVIc=
|
||||
github.com/lestrrat-go/dsig v1.2.1/go.mod h1:RD2eOaidyPvpc7IJQoO3Qq52RWdy8ZcJs8lrOnoa1Kc=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7gcrVVMFPOzY=
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5 h1:S+Mb4L2I+bM6JGTibLmxExhyTOqnXjqx+zi9MoXw/TM=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.5/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13 h1:AdHKiPIYeCSnOJtvdpipPg/0SuFh9rdkN+HF3O0VdSk=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss=
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs=
|
||||
github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
|
||||
github.com/mattn/go-sqlite3 v1.14.42 h1:MigqEP4ZmHw3aIdIT7T+9TLa90Z6smwcthx+Azv4Cgo=
|
||||
github.com/mattn/go-sqlite3 v1.14.42/go.mod h1:pjEuOr8IwzLJP2MfGeTb0A35jauH+C2kbHKBr7yXKVQ=
|
||||
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/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -257,6 +266,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nlnwa/whatwg-url v0.6.2 h1:jU61lU2ig4LANydbEJmA2nPrtCGiKdtgT0rmMd2VZ/Q=
|
||||
github.com/nlnwa/whatwg-url v0.6.2/go.mod h1:x0FPXJzzOEieQtsBT/AKvbiBbQ46YlL6Xa7m02M1ECk=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
|
||||
@@ -265,8 +276,10 @@ github.com/orandin/slog-gorm v1.4.0 h1:FgA8hJufF9/jeNSYoEXmHPPBwET2gwlF3B85JdpsT
|
||||
github.com/orandin/slog-gorm v1.4.0/go.mod h1:MoZ51+b7xE9lwGNPYEhxcUtRNrYzjdcKvA8QXQQGEPA=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1 h1:lA8FH0oOrM4u7mLvowq8IT6a3Q/qEnqRzLQn9eH5ojc=
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.1.1/go.mod h1:PLdx6PR+siSIoXqqy7C7r3SB3KZnhxWr1Dp6g0Hacl8=
|
||||
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/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
|
||||
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -280,8 +293,8 @@ github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTU
|
||||
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
|
||||
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
|
||||
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
|
||||
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
|
||||
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
|
||||
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
|
||||
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
|
||||
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.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
@@ -312,6 +325,8 @@ github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXl
|
||||
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/tinylib/msgp v1.6.3 h1:bCSxiTz386UTgyT1i0MSCvdbWjVW+8sG3PjkGsZQt4s=
|
||||
github.com/tinylib/msgp v1.6.3/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
@@ -320,116 +335,172 @@ github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADT
|
||||
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs=
|
||||
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms=
|
||||
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8=
|
||||
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4=
|
||||
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes=
|
||||
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g=
|
||||
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8=
|
||||
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI=
|
||||
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg=
|
||||
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw=
|
||||
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
|
||||
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0 h1:hhPGP3zvvy1xWT9RTy970wlniSxFttBIsAK1gvMguJM=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.18.0/go.mod h1:twJF7inoMza6kxMcF8JOdL3mPmtOZu7GEr34CUNE6Dg=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 h1:w3zlHYETbDwXyWHZlyyR58ZC39XGi8rAhkBgUgJ9d5w=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0/go.mod h1:GR/mClR2nn7vE8RLwxKjoBNg+QtgdDhRzxVa93koy5o=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 h1:0D3GFvELGIwQGfC6agLsbrEYSGWZTRTxIXxcQUqrOuk=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0/go.mod h1:DM2NV7Zb8CcGeVPt6glouY0FAiwZQ/iqgcWExhgWeN8=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0 h1:5FXSL2s6afUC1bzNzl1iedZZ8yqR7GOhbCoEXtyeK6Q=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.68.0/go.mod h1:MdHW7tLtkeGJnR4TyOrnd5D0zUGZQB1l84uHCe8hRpE=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0 h1:CETqV3QLLPTy5yNrqyMr41VnAOOD4lsRved7n4QG00A=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.43.0/go.mod h1:Q4mCiCdziYzpNR0g+6UqVotAlCDZdzz6L8jwY4knOrw=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0 h1:Dn8rkudDzY6KV9dr/D/bTUuWgqDf9xe0rr4G2elrn0Y=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.19.0/go.mod h1:gMk9F0xDgyN9M/3Ed5Y1wKcx/9mlU91NXY2SNq7RQuU=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0 h1:HIBTQ3VO5aupLKjC90JgMqpezVXwFuq6Ryjn0/izoag=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.19.0/go.mod h1:ji9vId85hMxqfvICA0Jt8JqEdrXaAkcpkI9HPXya0ro=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0 h1:8UQVDcZxOJLtX6gxtDt3vY2WTgvZqMQRzjsqiIHQdkc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.43.0/go.mod h1:2lmweYCiHYpEjQ/lSJBYhj9jP1zvCvQW4BqL9dnT7FQ=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0 h1:w1K+pCJoPpQifuVpsKamUdn9U0zM3xUziVOqsGksUrY=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.43.0/go.mod h1:HBy4BjzgVE8139ieRI75oXm3EcDN+6GhD88JT1Kjvxg=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0 h1:RAE+JPfvEmvy+0LzyUA25/SGawPwIUbZ6u0Wug54sLc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.43.0/go.mod h1:AGmbycVGEsRx9mXMZ75CsOyhSP6MFIcj/6dnG+vhVjk=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0 h1:jOveH/b4lU9HT7y+Gfamf18BqlOuz2PWEvs8yM7Q6XE=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.65.0/go.mod h1:i1P8pcumauPtUI4YNopea1dhzEMuEqWP1xoUZDylLHo=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0 h1:GJkybS+crDMdExT/BUNCEgfrmfboztcS6PhvSo88HKM=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.19.0/go.mod h1:NuAyxRYIG2lKX3YQkB+83StTxM7s52PUUkRRiC0wnYI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0 h1:TC+BewnDpeiAmcscXbGMfxkO+mwYUwE/VySwvw88PfA=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.43.0/go.mod h1:J/ZyF4vfPwsSr9xJSPyQ4LqtcTPULFR64KwTikGLe+A=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0 h1:mS47AX77OtFfKG4vtp+84kuGSFZHTyxtXIN269vChY0=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.43.0/go.mod h1:PJnsC41lAGncJlPUniSwM81gc80GkgWJWr3cu2nKEtU=
|
||||
go.opentelemetry.io/otel/log v0.19.0 h1:KUZs/GOsw79TBBMfDWsXS+KZ4g2Ckzksd1ymzsIEbo4=
|
||||
go.opentelemetry.io/otel/log v0.19.0/go.mod h1:5DQYeGmxVIr4n0/BcJvF4upsraHjg6vudJJpnkL6Ipk=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0 h1:scYVLqT22D2gqXItnWiocLUKGH9yvkkeql5dBDiXyko=
|
||||
go.opentelemetry.io/otel/sdk/log v0.19.0/go.mod h1:vFBowwXGLlW9AvpuF7bMgnNI95LiW10szrOdvzBHlAg=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0 h1:BEbF7ZBB6qQloV/Ub1+3NQoOUnVtcGkU3XX4Ws3GQfk=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.19.0/go.mod h1:Lua81/3yM0wOmoHTokLj9y9ADeA02v1naRrVrkAZuKk=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
|
||||
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.26.0 h1:jZ6dpec5haP/fUv1kLCbuJy6dnRrfX6iVK08lZBFpk4=
|
||||
golang.org/x/arch v0.26.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||
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.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0=
|
||||
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
|
||||
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
|
||||
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
|
||||
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
|
||||
golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
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/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
|
||||
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=
|
||||
google.golang.org/grpc v1.79.1/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d h1:/aDRtSZJjyLQzm75d+a1wOJaqyKBMvIAfeQmoa3ORiI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:etfGUgejTiadZAUaEP14NP97xi1RGeawqkjDARA/UOs=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -442,20 +513,20 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so=
|
||||
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw=
|
||||
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
|
||||
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||
modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U=
|
||||
modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8=
|
||||
modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU=
|
||||
modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0=
|
||||
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
|
||||
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
|
||||
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.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
|
||||
modernc.org/gc/v3 v3.1.2/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.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
modernc.org/libc v1.71.0 h1:bu0djXJGhqed3DnBzyzu3sY0fv432lesyz99ecEahyA=
|
||||
modernc.org/libc v1.71.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||
@@ -464,8 +535,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.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c=
|
||||
modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
|
||||
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=
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -20,6 +21,8 @@ import (
|
||||
|
||||
// initApplicationImages copies the images from the embedded directory to the storage backend
|
||||
// and returns a map containing the detected file extensions in the application-images directory.
|
||||
//
|
||||
//nolint:gocognit
|
||||
func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
|
||||
// Previous versions of images
|
||||
// If these are found, they are deleted
|
||||
@@ -76,6 +79,18 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
|
||||
dstNameToExt[nameWithoutExt] = ext
|
||||
}
|
||||
|
||||
initedPath := path.Join("application-images", ".inited")
|
||||
if _, _, err := fileStorage.Open(ctx, initedPath); err == nil {
|
||||
return dstNameToExt, nil
|
||||
} else if !os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("failed to read .inited: %w", err)
|
||||
} else {
|
||||
err := fileStorage.Save(ctx, initedPath, strings.NewReader(""))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to store .inited: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Copy images from the images directory to the application-images directory if they don't already exist
|
||||
for _, sourceFile := range sourceFiles {
|
||||
if sourceFile.IsDir() {
|
||||
|
||||
@@ -2,6 +2,7 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/job"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
@@ -60,7 +62,9 @@ func Bootstrap(ctx context.Context) error {
|
||||
}
|
||||
|
||||
waitUntil, err := svc.appLockService.Acquire(ctx, false)
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrLockUnavailable) {
|
||||
return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ func NewDatabase() (db *gorm.DB, err error) {
|
||||
}
|
||||
|
||||
// Run migrations
|
||||
if err := utils.MigrateDatabase(sqlDb); err != nil {
|
||||
err = utils.MigrateDatabase(sqlDb)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
|
||||
@@ -42,7 +43,10 @@ func NewDatabase() (db *gorm.DB, err error) {
|
||||
}
|
||||
|
||||
func ConnectDatabase() (db *gorm.DB, err error) {
|
||||
var dialector gorm.Dialector
|
||||
var (
|
||||
dialector gorm.Dialector
|
||||
sqliteNetworkFilesystem bool
|
||||
)
|
||||
|
||||
// Choose the correct database provider
|
||||
var onConnFn func(conn *sql.DB)
|
||||
@@ -63,6 +67,14 @@ func ConnectDatabase() (db *gorm.DB, err error) {
|
||||
if err := ensureSqliteDatabaseDir(dbPath); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath))
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
slog.Warn("Failed to detect filesystem type for the SQLite database directory", slog.String("path", filepath.Dir(dbPath)), slog.Any("error", err))
|
||||
} else if sqliteNetworkFilesystem {
|
||||
slog.Warn("⚠️⚠️⚠️ SQLite databases should not be stored on a networked file system like NFS, SMB, or FUSE, as there's a risk of crashes and even database corruption", slog.String("path", filepath.Dir(dbPath)))
|
||||
}
|
||||
}
|
||||
|
||||
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
|
||||
|
||||
@@ -2,6 +2,7 @@ package bootstrap
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
@@ -10,8 +11,11 @@ import (
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/fsnotify/fsnotify"
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
@@ -32,6 +36,53 @@ import (
|
||||
var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services)
|
||||
|
||||
func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
r, err := initEngine()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
registerRoutes(r, db, svc)
|
||||
|
||||
serverConfig, err := initServer(r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
runFn := func(ctx context.Context) error {
|
||||
return runServer(ctx, serverConfig)
|
||||
}
|
||||
|
||||
return runFn, nil
|
||||
}
|
||||
|
||||
type serverConfig struct {
|
||||
addr string
|
||||
certProvider *tlsCertProvider
|
||||
listener net.Listener
|
||||
server *http.Server
|
||||
tlsConfig *tls.Config
|
||||
}
|
||||
|
||||
func initEngine() (*gin.Engine, error) {
|
||||
setGinMode()
|
||||
|
||||
r := gin.New()
|
||||
initLogger(r)
|
||||
configureEngine(r)
|
||||
registerGlobalMiddleware(r)
|
||||
|
||||
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
|
||||
if err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware); err != nil {
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
return r, nil
|
||||
}
|
||||
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||
}
|
||||
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func setGinMode() {
|
||||
// Set the appropriate Gin mode based on the environment
|
||||
switch common.EnvConfig.AppEnv {
|
||||
case common.AppEnvProduction:
|
||||
@@ -41,45 +92,40 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
case common.AppEnvTest:
|
||||
gin.SetMode(gin.TestMode)
|
||||
}
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
initLogger(r)
|
||||
|
||||
func configureEngine(r *gin.Engine) {
|
||||
if !common.EnvConfig.TrustProxy {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
if common.EnvConfig.TrustedPlatform != "" {
|
||||
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
|
||||
}
|
||||
|
||||
if common.EnvConfig.TracingEnabled {
|
||||
r.Use(otelgin.Middleware(common.Name))
|
||||
}
|
||||
}
|
||||
|
||||
// Setup global middleware
|
||||
func registerGlobalMiddleware(r *gin.Engine) {
|
||||
r.Use(middleware.HeadMiddleware())
|
||||
r.Use(middleware.NewCacheControlMiddleware().Add())
|
||||
r.Use(middleware.NewCorsMiddleware().Add())
|
||||
r.Use(middleware.NewCspMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
}
|
||||
|
||||
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300)
|
||||
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware)
|
||||
if errors.Is(err, frontend.ErrFrontendNotIncluded) {
|
||||
slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to register frontend: %w", err)
|
||||
}
|
||||
|
||||
// Initialize middleware for specific routes
|
||||
func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) {
|
||||
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
|
||||
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
|
||||
|
||||
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
|
||||
|
||||
// Set up API routes
|
||||
apiGroup := r.Group("/api", apiRateLimitMiddleware)
|
||||
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
|
||||
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
|
||||
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.appConfigService)
|
||||
controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnService, svc.appConfigService)
|
||||
controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService)
|
||||
controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService)
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
@@ -89,30 +135,81 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
||||
|
||||
// Add test controller in non-production environments
|
||||
if !common.EnvConfig.AppEnv.IsProduction() {
|
||||
for _, f := range registerTestControllers {
|
||||
f(apiGroup, db, svc)
|
||||
}
|
||||
}
|
||||
registerTestRoutes(apiGroup, db, svc)
|
||||
|
||||
// Set up base routes
|
||||
baseGroup := r.Group("/", apiRateLimitMiddleware)
|
||||
controller.NewWellKnownController(baseGroup, svc.jwtService)
|
||||
|
||||
// Set up healthcheck routes
|
||||
// These are not rate-limited
|
||||
// These are not rate-limited.
|
||||
controller.NewHealthzController(r)
|
||||
}
|
||||
|
||||
var protocols http.Protocols
|
||||
func registerTestRoutes(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) {
|
||||
if common.EnvConfig.AppEnv.IsProduction() {
|
||||
return
|
||||
}
|
||||
|
||||
for _, f := range registerTestControllers {
|
||||
f(apiGroup, db, svc)
|
||||
}
|
||||
}
|
||||
|
||||
func initServer(r *gin.Engine) (*serverConfig, error) {
|
||||
protocols, tlsConfig, certProvider, err := initServerProtocols()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
network, addr := listenerNetworkAndAddr()
|
||||
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||
}
|
||||
|
||||
if err := setUnixSocketMode(network, addr); err != nil {
|
||||
listener.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &serverConfig{
|
||||
addr: addr,
|
||||
certProvider: certProvider,
|
||||
listener: listener,
|
||||
server: newHTTPServer(r, protocols),
|
||||
tlsConfig: tlsConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func initServerProtocols() (*http.Protocols, *tls.Config, *tlsCertProvider, error) {
|
||||
protocols := new(http.Protocols)
|
||||
protocols.SetHTTP1(true)
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
|
||||
// Set up the server
|
||||
srv := &http.Server{
|
||||
if common.EnvConfig.TLSCertFile == "" || common.EnvConfig.TLSKeyFile == "" {
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
return protocols, nil, nil, nil
|
||||
}
|
||||
|
||||
protocols.SetHTTP2(true)
|
||||
certProvider, err := newCertProvider(common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile)
|
||||
if err != nil {
|
||||
return nil, nil, nil, fmt.Errorf("failed to load TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
tlsConfig := &tls.Config{
|
||||
GetCertificate: certProvider.GetCertificate,
|
||||
MinVersion: tls.VersionTLS13,
|
||||
NextProtos: []string{"h2"},
|
||||
}
|
||||
|
||||
slog.Info("TLS enabled")
|
||||
return protocols, tlsConfig, certProvider, nil
|
||||
}
|
||||
|
||||
func newHTTPServer(r *gin.Engine, protocols *http.Protocols) *http.Server {
|
||||
return &http.Server{
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Protocols: &protocols,
|
||||
Protocols: protocols,
|
||||
Handler: h2c.NewHandler(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
|
||||
@@ -125,73 +222,115 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
r.ServeHTTP(w, req)
|
||||
}), &http2.Server{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the listener
|
||||
network := "tcp"
|
||||
addr := net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
|
||||
if common.EnvConfig.UnixSocket != "" {
|
||||
network = "unix"
|
||||
addr = common.EnvConfig.UnixSocket
|
||||
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
|
||||
func listenerNetworkAndAddr() (string, string) {
|
||||
if common.EnvConfig.UnixSocket == "" {
|
||||
return "tcp", net.JoinHostPort(common.EnvConfig.Host, common.EnvConfig.Port)
|
||||
}
|
||||
|
||||
listener, err := net.Listen(network, addr) //nolint:noctx
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create %s listener: %w", network, err)
|
||||
}
|
||||
|
||||
// Set the socket mode if using a Unix socket
|
||||
if network == "unix" && common.EnvConfig.UnixSocketMode != "" {
|
||||
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
|
||||
return nil, fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Service runner function
|
||||
runFn := func(ctx context.Context) error {
|
||||
slog.Info("Server listening", slog.String("addr", addr))
|
||||
|
||||
// Start the server in a background goroutine
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
|
||||
// Next call blocks until the server is shut down
|
||||
srvErr := srv.Serve(listener)
|
||||
if srvErr != http.ErrServerClosed {
|
||||
slog.Error("Error starting app server", "error", srvErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
// Notify systemd that we are ready
|
||||
err = systemd.SdNotifyReady()
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||
}
|
||||
|
||||
// Block until the context is canceled
|
||||
<-ctx.Done()
|
||||
|
||||
// Handle graceful shutdown
|
||||
// Note we use the background context here as ctx has been canceled already
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
shutdownErr := srv.Shutdown(shutdownCtx) //nolint:contextcheck
|
||||
shutdownCancel()
|
||||
if shutdownErr != nil {
|
||||
// Log the error only (could be context canceled)
|
||||
slog.Warn("App server shutdown error", "error", shutdownErr)
|
||||
}
|
||||
addr := common.EnvConfig.UnixSocket
|
||||
os.Remove(addr) // remove dangling the socket file to avoid file-exist error
|
||||
return "unix", addr
|
||||
}
|
||||
|
||||
func setUnixSocketMode(network, addr string) error {
|
||||
if network != "unix" || common.EnvConfig.UnixSocketMode == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
return runFn, nil
|
||||
mode, err := strconv.ParseUint(common.EnvConfig.UnixSocketMode, 8, 32)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
|
||||
if err := os.Chmod(addr, os.FileMode(mode)); err != nil {
|
||||
return fmt.Errorf("failed to set UNIX socket mode '%s': %w", common.EnvConfig.UnixSocketMode, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func runServer(ctx context.Context, config *serverConfig) error {
|
||||
slog.Info("Server listening", slog.String("addr", config.addr), slog.Bool("tls", config.tlsConfig != nil))
|
||||
|
||||
certWatcher, err := startCertWatcher(ctx, config.certProvider)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer closeCertWatcher(certWatcher)
|
||||
|
||||
startHTTPServer(config)
|
||||
notifySystemdReady()
|
||||
|
||||
<-ctx.Done()
|
||||
return shutdownServer(ctx, config.server)
|
||||
}
|
||||
|
||||
func startCertWatcher(ctx context.Context, certProvider *tlsCertProvider) (*fsnotify.Watcher, error) {
|
||||
if certProvider == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
certWatcher, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate watcher: %w", err)
|
||||
}
|
||||
|
||||
if err := certWatcher.Add(common.EnvConfig.TLSCertFile); err != nil {
|
||||
certWatcher.Close()
|
||||
return nil, fmt.Errorf("failed to watch TLS certificate: %w", err)
|
||||
}
|
||||
if err := certWatcher.Add(common.EnvConfig.TLSKeyFile); err != nil {
|
||||
certWatcher.Close()
|
||||
return nil, fmt.Errorf("failed to watch TLS key: %w", err)
|
||||
}
|
||||
|
||||
go certProvider.StartWatching(ctx, certWatcher)
|
||||
return certWatcher, nil
|
||||
}
|
||||
|
||||
func closeCertWatcher(certWatcher *fsnotify.Watcher) {
|
||||
if certWatcher != nil {
|
||||
certWatcher.Close()
|
||||
}
|
||||
}
|
||||
|
||||
func startHTTPServer(config *serverConfig) {
|
||||
go func() {
|
||||
defer config.listener.Close()
|
||||
|
||||
listener := config.listener
|
||||
if config.tlsConfig != nil {
|
||||
listener = tls.NewListener(config.listener, config.tlsConfig)
|
||||
}
|
||||
srvErr := config.server.Serve(listener)
|
||||
|
||||
if srvErr != http.ErrServerClosed {
|
||||
slog.Error("Error starting app server", "error", srvErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func notifySystemdReady() {
|
||||
err := systemd.SdNotifyReady()
|
||||
if err != nil {
|
||||
// Log the error only
|
||||
slog.Warn("Unable to notify systemd that the service is ready", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func shutdownServer(ctx context.Context, srv *http.Server) error {
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
shutdownErr := srv.Shutdown(shutdownCtx)
|
||||
shutdownCancel()
|
||||
if shutdownErr != nil {
|
||||
// Log the error only (could be context canceled)
|
||||
slog.Warn("App server shutdown error", "error", shutdownErr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func initLogger(r *gin.Engine) {
|
||||
@@ -220,3 +359,99 @@ func initLogger(r *gin.Engine) {
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
// tlsCertProvider holds certificates that can be dynamically reloaded
|
||||
type tlsCertProvider struct {
|
||||
certMutex sync.RWMutex
|
||||
cert *tls.Certificate
|
||||
certFile string
|
||||
keyFile string
|
||||
forceReload atomic.Bool
|
||||
}
|
||||
|
||||
// GetCertificate implements tls.GetCertificate interface for dynamic certificate loading
|
||||
func (p *tlsCertProvider) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
|
||||
if p.forceReload.Load() {
|
||||
p.certMutex.Lock()
|
||||
p.forceReload.Store(false)
|
||||
p.certMutex.Unlock()
|
||||
}
|
||||
|
||||
p.certMutex.RLock()
|
||||
defer p.certMutex.RUnlock()
|
||||
return p.cert, nil
|
||||
}
|
||||
|
||||
// newCertProvider creates a new certificate provider with initial certificates loaded
|
||||
func newCertProvider(certFile, keyFile string) (*tlsCertProvider, error) {
|
||||
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &tlsCertProvider{
|
||||
cert: &cert,
|
||||
certFile: certFile,
|
||||
keyFile: keyFile,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// reloadCertificate reloads the certificate from disk
|
||||
func (p *tlsCertProvider) reloadCertificate() error {
|
||||
cert, err := tls.LoadX509KeyPair(p.certFile, p.keyFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to reload TLS certificate: %w", err)
|
||||
}
|
||||
|
||||
p.certMutex.Lock()
|
||||
p.cert = &cert
|
||||
p.certMutex.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StartWatching begins monitoring the certificate files for changes with debouncing
|
||||
func (p *tlsCertProvider) StartWatching(ctx context.Context, watcher *fsnotify.Watcher) {
|
||||
debounceDuration := 1 * time.Second
|
||||
reloadTimer := time.NewTimer(debounceDuration)
|
||||
reloadTimer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case event, ok := <-watcher.Events:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
// Only process write/rename events for certificate/key files
|
||||
if event.Has(fsnotify.Write | fsnotify.Rename) {
|
||||
// Reset the debounce timer whenever we get a relevant event
|
||||
reloadTimer.Stop()
|
||||
// Drain the channel if there's a pending value
|
||||
select {
|
||||
case <-reloadTimer.C:
|
||||
default:
|
||||
}
|
||||
reloadTimer.Reset(debounceDuration)
|
||||
slog.Debug("TLS file change detected, debouncing", slog.String("path", event.Name))
|
||||
}
|
||||
case <-reloadTimer.C:
|
||||
// Timer fired - no more events in 500ms, so reload
|
||||
slog.Info("Reloading TLS certificate")
|
||||
|
||||
if err := p.reloadCertificate(); err != nil {
|
||||
slog.Error("Failed to reload TLS certificate", "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
p.forceReload.Store(true)
|
||||
slog.Info("TLS certificate reloaded successfully")
|
||||
case err, ok := <-watcher.Errors:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
slog.Error("Certificate watcher error", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,11 +119,10 @@ func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
|
||||
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")
|
||||
}
|
||||
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")
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to acquire application lock: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ type EnvConfigSchema struct {
|
||||
DbProvider DbProvider
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
TrustProxy bool `env:"TRUST_PROXY"`
|
||||
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
|
||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
|
||||
@@ -69,6 +70,9 @@ type EnvConfigSchema struct {
|
||||
UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
|
||||
LocalIPv6Ranges string `env:"LOCAL_IPV6_RANGES"`
|
||||
|
||||
TLSCertFile string `env:"TLS_CERT" options:"file"`
|
||||
TLSKeyFile string `env:"TLS_KEY" options:"file"`
|
||||
|
||||
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
|
||||
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
|
||||
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
|
||||
@@ -141,6 +145,31 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
return errors.New("ENCRYPTION_KEY must be at least 16 bytes long")
|
||||
}
|
||||
|
||||
prepareDbConfig(config)
|
||||
|
||||
if err := validateAppURLs(config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateFileBackend(config); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if config.AuditLogRetentionDays <= 0 {
|
||||
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
}
|
||||
|
||||
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
|
||||
return errors.New("STATIC_API_KEY must be at least 16 characters long")
|
||||
}
|
||||
|
||||
return validateTLSConfig(config)
|
||||
|
||||
}
|
||||
|
||||
func prepareDbConfig(config *EnvConfigSchema) {
|
||||
switch {
|
||||
case config.DbConnectionString == "":
|
||||
config.DbProvider = DbProviderSqlite
|
||||
@@ -150,64 +179,95 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
default:
|
||||
config.DbProvider = DbProviderSqlite
|
||||
}
|
||||
}
|
||||
|
||||
parsedAppUrl, err := url.Parse(config.AppURL)
|
||||
if err != nil {
|
||||
return errors.New("APP_URL is not a valid URL")
|
||||
}
|
||||
if parsedAppUrl.Path != "" {
|
||||
return errors.New("APP_URL must not contain a path")
|
||||
func validateAppURLs(config *EnvConfigSchema) error {
|
||||
if err := validateURLWithoutPath(config.AppURL, "APP_URL"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
|
||||
if config.InternalAppURL == "" {
|
||||
config.InternalAppURL = config.AppURL
|
||||
} else {
|
||||
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
|
||||
if err != nil {
|
||||
return errors.New("INTERNAL_APP_URL is not a valid URL")
|
||||
}
|
||||
if parsedInternalAppUrl.Path != "" {
|
||||
return errors.New("INTERNAL_APP_URL must not contain a path")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return validateURLWithoutPath(config.InternalAppURL, "INTERNAL_APP_URL")
|
||||
}
|
||||
|
||||
func validateURLWithoutPath(rawURL, envName string) error {
|
||||
parsedURL, err := url.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s is not a valid URL", envName)
|
||||
}
|
||||
if parsedURL.Path != "" {
|
||||
return fmt.Errorf("%s must not contain a path", envName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateFileBackend(config *EnvConfigSchema) error {
|
||||
switch config.FileBackend {
|
||||
case "s3", "database":
|
||||
// All good, these are valid values
|
||||
return nil
|
||||
case "", "filesystem":
|
||||
if config.UploadPath == "" {
|
||||
config.UploadPath = defaultFsUploadPath
|
||||
}
|
||||
return nil
|
||||
default:
|
||||
return errors.New("invalid FILE_BACKEND value. Must be 'filesystem', 'database', or 's3'")
|
||||
}
|
||||
}
|
||||
|
||||
// Validate LOCAL_IPV6_RANGES
|
||||
ranges := strings.SplitSeq(config.LocalIPv6Ranges, ",")
|
||||
func validateLocalIPv6Ranges(localIPv6Ranges string) error {
|
||||
ranges := strings.SplitSeq(localIPv6Ranges, ",")
|
||||
for rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
|
||||
if err := validateLocalIPv6Range(rangeStr); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if ipNet.IP.To4() != nil {
|
||||
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
if config.AuditLogRetentionDays <= 0 {
|
||||
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateLocalIPv6Range(rangeStr string) error {
|
||||
_, ipNet, err := net.ParseCIDR(rangeStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid LOCAL_IPV6_RANGES '%s': %w", rangeStr, err)
|
||||
}
|
||||
|
||||
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
|
||||
return errors.New("STATIC_API_KEY must be at least 16 characters long")
|
||||
if ipNet.IP.To4() != nil {
|
||||
return fmt.Errorf("range '%s' is not a valid IPv6 range", rangeStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateTLSConfig(config *EnvConfigSchema) error {
|
||||
switch {
|
||||
case config.TLSCertFile != "" && config.TLSKeyFile == "":
|
||||
return errors.New("TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
|
||||
case config.TLSCertFile == "" && config.TLSKeyFile != "":
|
||||
return errors.New("TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
|
||||
}
|
||||
|
||||
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
|
||||
if _, err := os.Stat(config.TLSCertFile); err != nil {
|
||||
return fmt.Errorf("TLS_CERT_FILE not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if config.TLSCertFile != "" && config.TLSKeyFile != "" {
|
||||
if _, err := os.Stat(config.TLSKeyFile); err != nil {
|
||||
return fmt.Errorf("TLS_KEY_FILE not found: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -207,6 +207,58 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid FILE_BACKEND value")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS cert is set without key", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("TLS_CERT", "/path/to/cert.pem")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_KEY_FILE must be set when TLS_CERT_FILE is set")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS key is set without cert", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("TLS_KEY", "/path/to/key.pem")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_CERT_FILE must be set when TLS_KEY_FILE is set")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS cert file does not exist", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
t.Setenv("TLS_CERT", "/nonexistent/cert.pem")
|
||||
|
||||
keyFile := t.TempDir() + "/key.pem"
|
||||
require.NoError(t, os.WriteFile(keyFile, []byte("key"), 0600))
|
||||
t.Setenv("TLS_KEY", keyFile)
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_CERT_FILE not found")
|
||||
})
|
||||
|
||||
t.Run("should fail when TLS key file does not exist", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
|
||||
t.Setenv("APP_URL", "http://localhost:3000")
|
||||
|
||||
certFile := t.TempDir() + "/cert.pem"
|
||||
require.NoError(t, os.WriteFile(certFile, []byte("cert"), 0600))
|
||||
t.Setenv("TLS_CERT", certFile)
|
||||
t.Setenv("TLS_KEY", "/nonexistent/key.pem")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "TLS_KEY_FILE not found")
|
||||
})
|
||||
}
|
||||
|
||||
func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
@@ -254,4 +306,26 @@ func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, binaryKeyContent, config.EncryptionKey)
|
||||
})
|
||||
|
||||
t.Run("should load TLS cert and key file contents", func(t *testing.T) {
|
||||
config := defaultConfig()
|
||||
|
||||
certFile := tempDir + "/cert.pem"
|
||||
certContent := "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
||||
err := os.WriteFile(certFile, []byte(certContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
keyFile := tempDir + "/key.pem"
|
||||
keyContent := "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----"
|
||||
err = os.WriteFile(keyFile, []byte(keyContent), 0600)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Setenv("TLS_CERT_FILE", certFile)
|
||||
t.Setenv("TLS_KEY_FILE", keyFile)
|
||||
|
||||
err = prepareEnvConfig(&config)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, certContent, config.TLSCertFile)
|
||||
assert.Equal(t, keyContent, config.TLSKeyFile)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ type AlreadyInUseError struct {
|
||||
func (e *AlreadyInUseError) Error() string {
|
||||
return e.Property + " is already in use"
|
||||
}
|
||||
func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
|
||||
func (e *AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
func (e *AlreadyInUseError) Is(target error) bool {
|
||||
// Ignore the field property when checking if an error is of the type AlreadyInUseError
|
||||
@@ -28,29 +28,29 @@ func (e *AlreadyInUseError) Is(target error) bool {
|
||||
return errors.As(target, &x)
|
||||
}
|
||||
|
||||
type SetupAlreadyCompletedError struct{}
|
||||
type SetupNotAvailableError struct{}
|
||||
|
||||
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||
func (e *SetupNotAvailableError) Error() string { return "not found" }
|
||||
func (e *SetupNotAvailableError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type TokenInvalidOrExpiredError struct{}
|
||||
|
||||
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
|
||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
|
||||
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type DeviceCodeInvalid struct{}
|
||||
|
||||
func (e *DeviceCodeInvalid) Error() string {
|
||||
return "one time access code must be used on the device it was generated for"
|
||||
}
|
||||
func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
|
||||
func (e *DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type TokenInvalidError struct{}
|
||||
|
||||
func (e *TokenInvalidError) Error() string {
|
||||
return "Token is invalid"
|
||||
}
|
||||
func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
|
||||
func (e *TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OidcMissingAuthorizationError struct{}
|
||||
|
||||
@@ -60,46 +60,51 @@ func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.Statu
|
||||
type OidcGrantTypeNotSupportedError struct{}
|
||||
|
||||
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
|
||||
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcMissingClientCredentialsError struct{}
|
||||
|
||||
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
|
||||
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcClientSecretInvalidError struct{}
|
||||
|
||||
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
|
||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OidcClientAssertionInvalidError struct{}
|
||||
|
||||
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
|
||||
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized }
|
||||
|
||||
type OidcInvalidAuthorizationCodeError struct{}
|
||||
|
||||
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
|
||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcClientNotFoundError struct{}
|
||||
|
||||
func (e *OidcClientNotFoundError) Error() string { return "client not found" }
|
||||
func (e *OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type OidcMissingCallbackURLError struct{}
|
||||
|
||||
func (e *OidcMissingCallbackURLError) Error() string {
|
||||
return "unable to detect callback url, it might be necessary for an admin to fix this"
|
||||
}
|
||||
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInvalidCallbackURLError struct{}
|
||||
|
||||
func (e *OidcInvalidCallbackURLError) Error() string {
|
||||
return "invalid callback URL, it might be necessary for an admin to fix this"
|
||||
}
|
||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
|
||||
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type FileTypeNotSupportedError struct{}
|
||||
|
||||
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
|
||||
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
|
||||
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type FileTooLargeError struct {
|
||||
MaxSize string
|
||||
@@ -134,6 +139,20 @@ func (e *TooManyRequestsError) Error() string {
|
||||
}
|
||||
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests }
|
||||
|
||||
type UserIdNotProvidedError struct{}
|
||||
|
||||
func (e *UserIdNotProvidedError) Error() string {
|
||||
return "User id not provided"
|
||||
}
|
||||
func (e *UserIdNotProvidedError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type UserNotFoundError struct{}
|
||||
|
||||
func (e *UserNotFoundError) Error() string {
|
||||
return "User not found"
|
||||
}
|
||||
func (e *UserNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
|
||||
|
||||
type ClientIdOrSecretNotProvidedError struct{}
|
||||
|
||||
func (e *ClientIdOrSecretNotProvidedError) Error() string {
|
||||
@@ -429,3 +448,27 @@ func (e *InvalidEmailVerificationTokenError) Error() string {
|
||||
func (e *InvalidEmailVerificationTokenError) HttpStatusCode() int {
|
||||
return http.StatusBadRequest
|
||||
}
|
||||
|
||||
// OIDC prompt parameter errors - used for redirect error responses
|
||||
|
||||
type OidcLoginRequiredError struct{}
|
||||
|
||||
func (e *OidcLoginRequiredError) Error() string { return "login_required" }
|
||||
func (e *OidcLoginRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcConsentRequiredError struct{}
|
||||
|
||||
func (e *OidcConsentRequiredError) Error() string { return "consent_required" }
|
||||
func (e *OidcConsentRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcInteractionRequiredError struct{}
|
||||
|
||||
func (e *OidcInteractionRequiredError) Error() string { return "interaction_required" }
|
||||
func (e *OidcInteractionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type OidcAccountSelectionRequiredError struct{}
|
||||
|
||||
func (e *OidcAccountSelectionRequiredError) Error() string {
|
||||
return "account_selection_required"
|
||||
}
|
||||
func (e *OidcAccountSelectionRequiredError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
@@ -2,7 +2,9 @@ package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -34,6 +36,7 @@ func NewAppImagesController(
|
||||
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
|
||||
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture)
|
||||
|
||||
group.DELETE("/application-images/background", authMiddleware.Add(), controller.deleteBackgroundImageHandler)
|
||||
group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
|
||||
}
|
||||
|
||||
@@ -192,12 +195,27 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteBackgroundImageHandler godoc
|
||||
// @Summary Delete background image
|
||||
// @Description Delete the application background image
|
||||
// @Tags Application Images
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/background [delete]
|
||||
func (c *AppImagesController) deleteBackgroundImageHandler(ctx *gin.Context) {
|
||||
if err := c.appImagesService.DeleteImage(ctx.Request.Context(), "background"); err != nil {
|
||||
_ = ctx.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
ctx.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// updateFaviconHandler godoc
|
||||
// @Summary Update favicon
|
||||
// @Description Update the application favicon
|
||||
// @Tags Application Images
|
||||
// @Accept multipart/form-data
|
||||
// @Param file formData file true "Favicon file (.ico)"
|
||||
// @Param file formData file true "Favicon file (.svg/.png/.ico)"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/application-images/favicon [put]
|
||||
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||
@@ -208,8 +226,9 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
|
||||
}
|
||||
|
||||
fileType := utils.GetFileExtension(file.Filename)
|
||||
if fileType != "ico" {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
|
||||
mimeType := utils.GetImageMimeType(strings.ToLower(fileType))
|
||||
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) {
|
||||
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ func (alc *AuditLogController) listAuditLogsForUserHandler(c *gin.Context) {
|
||||
// Add device information to the logs
|
||||
for i, logsDto := range logsDtos {
|
||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||
logsDto.ActorUsername = logsDto.Data["actorUsername"]
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
@@ -101,6 +102,7 @@ func (alc *AuditLogController) listAllAuditLogsHandler(c *gin.Context) {
|
||||
for i, logsDto := range logsDtos {
|
||||
logsDto.Device = alc.auditLogService.DeviceStringFromUserAgent(logs[i].UserAgent)
|
||||
logsDto.Username = logs[i].User.Username
|
||||
logsDto.ActorUsername = logsDto.Data["actorUsername"]
|
||||
logsDtos[i] = logsDto
|
||||
}
|
||||
|
||||
|
||||
@@ -89,13 +89,29 @@ type OidcController struct {
|
||||
// @Router /api/oidc/authorize [post]
|
||||
func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
var input dto.AuthorizeOidcClientRequestDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
|
||||
code, callbackURL, err := oc.oidcService.Authorize(
|
||||
c.Request.Context(),
|
||||
input,
|
||||
c.GetString("userID"),
|
||||
c.GetString("authenticationMethod"),
|
||||
c.ClientIP(),
|
||||
c.Request.UserAgent(),
|
||||
)
|
||||
if err != nil {
|
||||
// Check if this is a prompt-related error that should be returned as a redirect error
|
||||
if isOidcPromptError(err) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"error": err.Error(),
|
||||
"requiresRedirect": true,
|
||||
})
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -109,6 +125,19 @@ func (oc *OidcController) authorizeHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, response)
|
||||
}
|
||||
|
||||
// isOidcPromptError checks if an error is a prompt-related OIDC error that should trigger a redirect
|
||||
func isOidcPromptError(err error) bool {
|
||||
var loginReq *common.OidcLoginRequiredError
|
||||
var consentReq *common.OidcConsentRequiredError
|
||||
var interactionReq *common.OidcInteractionRequiredError
|
||||
var accountSelectionReq *common.OidcAccountSelectionRequiredError
|
||||
|
||||
return errors.As(err, &loginReq) ||
|
||||
errors.As(err, &consentReq) ||
|
||||
errors.As(err, &interactionReq) ||
|
||||
errors.As(err, &accountSelectionReq)
|
||||
}
|
||||
|
||||
// authorizationConfirmationRequiredHandler godoc
|
||||
// @Summary Check if authorization confirmation is required
|
||||
// @Description Check if the user needs to confirm authorization for the client
|
||||
@@ -335,11 +364,13 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
|
||||
)
|
||||
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request)
|
||||
if !ok {
|
||||
// If there's no basic auth, check if we have a bearer token
|
||||
// If there's no basic auth, check if we have a bearer token (used as client assertion)
|
||||
bearer, ok := utils.BearerAuth(c.Request)
|
||||
if ok {
|
||||
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
|
||||
creds.ClientAssertion = bearer
|
||||
// When using client assertions, client_id can be passed as a form field
|
||||
creds.ClientID = input.ClientID
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,8 +693,13 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (oc *OidcController) deviceAuthorizationHandler(c *gin.Context) {
|
||||
// Per RFC 8628 (OAuth 2.0 Device Authorization Grant), parameters for the device authorization request MUST be sent in the body of the POST request
|
||||
// Gin's "ShouldBind" by default reads from the query string too, so we need to reset all query string args before invoking ShouldBind
|
||||
c.Request.URL.RawQuery = ""
|
||||
|
||||
var input dto.OidcDeviceAuthorizationRequestDto
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
err := c.ShouldBind(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -794,7 +830,14 @@ func (oc *OidcController) verifyDeviceCodeHandler(c *gin.Context) {
|
||||
ipAddress := c.ClientIP()
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
err := oc.oidcService.VerifyDeviceCode(c.Request.Context(), userCode, c.GetString("userID"), ipAddress, userAgent)
|
||||
err := oc.oidcService.VerifyDeviceCode(
|
||||
c.Request.Context(),
|
||||
userCode,
|
||||
c.GetString("userID"),
|
||||
c.GetString("authenticationMethod"),
|
||||
ipAddress,
|
||||
userAgent)
|
||||
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
@@ -850,7 +893,13 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
|
||||
preview, err := oc.oidcService.GetClientPreview(
|
||||
c.Request.Context(),
|
||||
clientID,
|
||||
userID,
|
||||
strings.Split(scopes, " "),
|
||||
c.GetString("authenticationMethod"))
|
||||
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -20,10 +21,11 @@ const defaultOneTimeAccessTokenDuration = 15 * time.Minute
|
||||
// @Summary User management controller
|
||||
// @Description Initializes all user-related API endpoints
|
||||
// @Tags Users
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, appConfigService *service.AppConfigService) {
|
||||
func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, rateLimitMiddleware *middleware.RateLimitMiddleware, userService *service.UserService, oneTimeAccessService *service.OneTimeAccessService, webAuthnService *service.WebAuthnService, appConfigService *service.AppConfigService) {
|
||||
uc := UserController{
|
||||
userService: userService,
|
||||
oneTimeAccessService: oneTimeAccessService,
|
||||
webAuthnService: webAuthnService,
|
||||
appConfigService: appConfigService,
|
||||
}
|
||||
|
||||
@@ -33,8 +35,10 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
group.POST("/users", authMiddleware.Add(), uc.createUserHandler)
|
||||
group.PUT("/users/:id", authMiddleware.Add(), uc.updateUserHandler)
|
||||
group.GET("/users/:id/groups", authMiddleware.Add(), uc.getUserGroupsHandler)
|
||||
group.GET("/users/:id/webauthn-credentials", authMiddleware.Add(), uc.listUserWebauthnCredentialsHandler)
|
||||
group.PUT("/users/me", authMiddleware.WithAdminNotRequired().Add(), uc.updateCurrentUserHandler)
|
||||
group.DELETE("/users/:id", authMiddleware.Add(), uc.deleteUserHandler)
|
||||
group.DELETE("/users/:id/webauthn-credentials/:credentialId", authMiddleware.Add(), uc.deleteUserWebauthnCredentialHandler)
|
||||
|
||||
group.PUT("/users/:id/user-groups", authMiddleware.Add(), uc.updateUserGroups)
|
||||
|
||||
@@ -59,6 +63,7 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
type UserController struct {
|
||||
userService *service.UserService
|
||||
oneTimeAccessService *service.OneTimeAccessService
|
||||
webAuthnService *service.WebAuthnService
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
@@ -86,6 +91,36 @@ func (uc *UserController) getUserGroupsHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, groupsDto)
|
||||
}
|
||||
|
||||
// listUserWebauthnCredentialsHandler godoc
|
||||
// @Summary List user passkeys
|
||||
// @Description Retrieve all WebAuthn credentials for a specific user
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Success 200 {array} dto.WebauthnCredentialDto
|
||||
// @Router /api/users/{id}/webauthn-credentials [get]
|
||||
func (uc *UserController) listUserWebauthnCredentialsHandler(c *gin.Context) {
|
||||
userID := c.Param("id")
|
||||
|
||||
if _, err := uc.userService.GetUser(c.Request.Context(), userID); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
credentials, err := uc.webAuthnService.ListCredentials(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var credentialDtos []dto.WebauthnCredentialDto
|
||||
if err := dto.MapStructList(credentials, &credentialDtos); err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, credentialDtos)
|
||||
}
|
||||
|
||||
// listUsersHandler godoc
|
||||
// @Summary List users
|
||||
// @Description Get a paginated list of users with optional search and sorting
|
||||
@@ -180,6 +215,31 @@ func (uc *UserController) deleteUserHandler(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// deleteUserWebauthnCredentialHandler godoc
|
||||
// @Summary Delete user passkey
|
||||
// @Description Delete a specific WebAuthn credential for a user
|
||||
// @Tags Users
|
||||
// @Param id path string true "User ID"
|
||||
// @Param credentialId path string true "Credential ID"
|
||||
// @Success 204 "No Content"
|
||||
// @Router /api/users/{id}/webauthn-credentials/{credentialId} [delete]
|
||||
func (uc *UserController) deleteUserWebauthnCredentialHandler(c *gin.Context) {
|
||||
err := uc.webAuthnService.DeleteCredential(
|
||||
c.Request.Context(),
|
||||
c.Param("id"),
|
||||
c.Param("credentialId"),
|
||||
c.ClientIP(),
|
||||
c.Request.UserAgent(),
|
||||
c.GetString("userID"),
|
||||
)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// createUserHandler godoc
|
||||
// @Summary Create user
|
||||
// @Description Create a new user
|
||||
@@ -322,22 +382,34 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
|
||||
|
||||
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
|
||||
var input dto.OneTimeAccessTokenCreateDto
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
err := c.ShouldBindJSON(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
var ttl time.Duration
|
||||
var (
|
||||
userID string
|
||||
ttl time.Duration
|
||||
)
|
||||
if own {
|
||||
input.UserID = c.GetString("userID")
|
||||
// Get user ID from context and force the default TTL
|
||||
userID = c.GetString("userID")
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
} else {
|
||||
// Get user ID from URL parameter, and optional TTL from body
|
||||
userID = c.Param("id")
|
||||
ttl = input.TTL.Duration
|
||||
if ttl <= 0 {
|
||||
ttl = defaultOneTimeAccessTokenDuration
|
||||
}
|
||||
}
|
||||
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
|
||||
if userID == "" {
|
||||
_ = c.Error(&common.UserIdNotProvidedError{})
|
||||
return
|
||||
}
|
||||
|
||||
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
@@ -30,6 +31,7 @@ func NewUserSignupController(group *gin.RouterGroup, authMiddleware *middleware.
|
||||
group.GET("/signup-tokens", authMiddleware.Add(), usc.listSignupTokensHandler)
|
||||
group.DELETE("/signup-tokens/:id", authMiddleware.Add(), usc.deleteSignupTokenHandler)
|
||||
group.POST("/signup", rateLimitMiddleware.Add(rate.Every(1*time.Minute), 10), usc.signupHandler)
|
||||
group.GET("/signup/setup", usc.checkInitialAdminSetupAvailable)
|
||||
group.POST("/signup/setup", usc.signUpInitialAdmin)
|
||||
|
||||
}
|
||||
@@ -39,6 +41,21 @@ type UserSignupController struct {
|
||||
appConfigService *service.AppConfigService
|
||||
}
|
||||
|
||||
func (usc *UserSignupController) checkInitialAdminSetupAvailable(c *gin.Context) {
|
||||
setupCompleted, err := usc.userSignUpService.IsInitialAdminSetupCompleted(c.Request.Context())
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
if setupCompleted {
|
||||
_ = c.Error(&common.SetupNotAvailableError{})
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// signUpInitialAdmin godoc
|
||||
// @Summary Sign up initial admin user
|
||||
// @Description Sign up and generate setup access token for initial admin user
|
||||
|
||||
@@ -137,7 +137,7 @@ func (wc *WebauthnController) deleteCredentialHandler(c *gin.Context) {
|
||||
clientIP := c.ClientIP()
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent)
|
||||
err := wc.webAuthnService.DeleteCredential(c.Request.Context(), userID, credentialID, clientIP, userAgent, userID)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
|
||||
@@ -91,6 +91,8 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
|
||||
"id_token_signing_alg_values_supported": []string{alg.String()},
|
||||
"authorization_response_iss_parameter_supported": true,
|
||||
"code_challenge_methods_supported": []string{"plain", "S256"},
|
||||
"prompt_values_supported": []string{"none", "login", "consent"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ type AuditLogDto struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt datatype.DateTime `json:"createdAt"`
|
||||
|
||||
Event string `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
UserID string `json:"userID"`
|
||||
Username string `json:"username"`
|
||||
Data map[string]string `json:"data"`
|
||||
Event string `json:"event"`
|
||||
IpAddress string `json:"ipAddress"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Device string `json:"device"`
|
||||
UserID string `json:"userID"`
|
||||
Username string `json:"username"`
|
||||
ActorUsername string `json:"actorUsername"`
|
||||
Data map[string]string `json:"data"`
|
||||
}
|
||||
|
||||
@@ -33,8 +33,8 @@ type OidcClientWithAllowedGroupsCountDto struct {
|
||||
|
||||
type OidcClientUpdateDto struct {
|
||||
Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
|
||||
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"`
|
||||
IsPublic bool `json:"isPublic"`
|
||||
PkceEnabled bool `json:"pkceEnabled"`
|
||||
RequiresReauthentication bool `json:"requiresReauthentication"`
|
||||
@@ -66,11 +66,12 @@ type OidcClientFederatedIdentityDto struct {
|
||||
type AuthorizeOidcClientRequestDto struct {
|
||||
ClientID string `json:"clientID" binding:"required"`
|
||||
Scope string `json:"scope" binding:"required"`
|
||||
CallbackURL string `json:"callbackURL"`
|
||||
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"`
|
||||
Nonce string `json:"nonce"`
|
||||
CodeChallenge string `json:"codeChallenge"`
|
||||
CodeChallengeMethod string `json:"codeChallengeMethod"`
|
||||
ReauthenticationToken string `json:"reauthenticationToken"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
type AuthorizeOidcClientResponseDto struct {
|
||||
@@ -98,7 +99,8 @@ type OidcCreateTokensDto struct {
|
||||
}
|
||||
|
||||
type OidcIntrospectDto struct {
|
||||
Token string `form:"token" binding:"required"`
|
||||
Token string `form:"token" binding:"required"`
|
||||
ClientID string `form:"client_id"`
|
||||
}
|
||||
|
||||
type OidcUpdateAllowedUserGroupsDto struct {
|
||||
|
||||
@@ -3,8 +3,7 @@ package dto
|
||||
import "github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
|
||||
type OneTimeAccessTokenCreateDto struct {
|
||||
UserID string `json:"userId"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
|
||||
}
|
||||
|
||||
type OneTimeAccessEmailAsUnauthenticatedUserDto struct {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package dto
|
||||
|
||||
type SignUpDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
@@ -23,12 +23,12 @@ type UserDto struct {
|
||||
}
|
||||
|
||||
type UserCreateDto struct {
|
||||
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
|
||||
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"`
|
||||
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
|
||||
EmailVerified bool `json:"emailVerified"`
|
||||
FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
|
||||
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"`
|
||||
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
|
||||
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"`
|
||||
IsAdmin bool `json:"isAdmin"`
|
||||
Locale *string `json:"locale"`
|
||||
Disabled bool `json:"disabled"`
|
||||
|
||||
@@ -33,14 +33,24 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
},
|
||||
wantErr: "Field validation for 'Username' failed on the 'required' tag",
|
||||
},
|
||||
{
|
||||
name: "missing first name",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: new("test@example.com"),
|
||||
LastName: "Doe",
|
||||
},
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "missing display name",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "username contains invalid characters",
|
||||
@@ -73,7 +83,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "last name too long",
|
||||
|
||||
@@ -15,43 +15,44 @@ import (
|
||||
// [a-zA-Z0-9] : The username must start with an alphanumeric character
|
||||
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
|
||||
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
|
||||
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
|
||||
// (...)? : This allows single-character usernames (just one alphanumeric character)
|
||||
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$")
|
||||
|
||||
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
|
||||
|
||||
func init() {
|
||||
v := binding.Validator.Engine().(*validator.Validate)
|
||||
engine := binding.Validator.Engine().(*validator.Validate)
|
||||
|
||||
// Maximum allowed value for TTLs
|
||||
const maxTTL = 31 * 24 * time.Hour
|
||||
|
||||
if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
|
||||
return ValidateUsername(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for username: " + err.Error())
|
||||
validators := map[string]validator.Func{
|
||||
"username": func(fl validator.FieldLevel) bool {
|
||||
return ValidateUsername(fl.Field().String())
|
||||
},
|
||||
"client_id": func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
},
|
||||
"ttl": func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
// Allow zero, which means the field wasn't set
|
||||
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
|
||||
},
|
||||
"callback_url": func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURL(fl.Field().String())
|
||||
},
|
||||
"callback_url_pattern": func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURLPattern(fl.Field().String())
|
||||
},
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
|
||||
return ValidateClientID(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for client_id: " + err.Error())
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
|
||||
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
|
||||
if !ok {
|
||||
return false
|
||||
for k, v := range validators {
|
||||
err := engine.RegisterValidation(k, v)
|
||||
if err != nil {
|
||||
panic("Failed to register custom validation for " + k + ": " + err.Error())
|
||||
}
|
||||
// Allow zero, which means the field wasn't set
|
||||
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for ttl: " + err.Error())
|
||||
}
|
||||
|
||||
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
|
||||
return ValidateCallbackURL(fl.Field().String())
|
||||
}); err != nil {
|
||||
panic("Failed to register custom validation for callback_url: " + err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,21 +66,24 @@ func ValidateClientID(clientID string) bool {
|
||||
return validateClientIDRegex.MatchString(clientID)
|
||||
}
|
||||
|
||||
// ValidateCallbackURL validates callback URLs with support for wildcards
|
||||
func ValidateCallbackURL(raw string) bool {
|
||||
// Don't validate if it contains a wildcard
|
||||
if strings.Contains(raw, "*") {
|
||||
return true
|
||||
}
|
||||
|
||||
u, err := url.Parse(raw)
|
||||
// ValidateCallbackURL validates the input callback URL
|
||||
func ValidateCallbackURL(str string) bool {
|
||||
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:"
|
||||
u, err := url.Parse(str)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !u.IsAbs() {
|
||||
switch strings.ToLower(u.Scheme) {
|
||||
case "javascript", "data":
|
||||
return false
|
||||
default:
|
||||
return true
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards
|
||||
func ValidateCallbackURLPattern(raw string) bool {
|
||||
err := utils.ValidateCallbackURLPattern(raw)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ func TestValidateUsername(t *testing.T) {
|
||||
{"starts with symbol", ".username", false},
|
||||
{"ends with non-alphanumeric", "username-", false},
|
||||
{"contains space", "user name", false},
|
||||
{"valid single char", "a", true},
|
||||
{"empty", "", false},
|
||||
{"only special chars", "-._@", false},
|
||||
{"valid long", "a1234567890_b.c-d@e", true},
|
||||
@@ -56,3 +57,28 @@ func TestValidateClientID(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCallbackURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected bool
|
||||
}{
|
||||
{"valid https URL", "https://example.com/callback", true},
|
||||
{"valid loopback URL", "http://127.0.0.1:49813/callback", true},
|
||||
{"empty scheme", "//127.0.0.1:49813/callback", true},
|
||||
{"valid custom scheme", "pocketid://callback", true},
|
||||
{"invalid malformed URL", "http://[::1", false},
|
||||
{"invalid missing scheme separator", "://example.com/callback", false},
|
||||
{"rejects javascript scheme", "javascript:alert(1)", false},
|
||||
{"rejects mixed case javascript scheme", "JavaScript:alert(1)", false},
|
||||
{"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
|
||||
{"rejects mixed case data scheme", "DaTa:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, ValidateCallbackURL(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
|
||||
appConfig: appConfig,
|
||||
httpClient: httpClient,
|
||||
}
|
||||
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
|
||||
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true})
|
||||
}
|
||||
|
||||
type AnalyticsJob struct {
|
||||
|
||||
@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
|
||||
}
|
||||
|
||||
// Send every day at midnight
|
||||
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
|
||||
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{})
|
||||
}
|
||||
|
||||
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
|
||||
@@ -42,7 +42,11 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
|
||||
}
|
||||
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
|
||||
slog.ErrorContext(ctx, "Failed to send expiring API key notification email",
|
||||
slog.String("key", key.ID),
|
||||
slog.String("user", key.User.ID),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -7,28 +7,37 @@ import (
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
backoff "github.com/cenkalti/backoff/v5"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
|
||||
jobs := &DbCleanupJobs{db: db}
|
||||
|
||||
// Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
|
||||
def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
|
||||
newBackOff := func() *backoff.ExponentialBackOff {
|
||||
bo := backoff.NewExponentialBackOff()
|
||||
bo.Multiplier = 4
|
||||
bo.RandomizationFactor = 0.1
|
||||
bo.InitialInterval = time.Second
|
||||
bo.MaxInterval = 45 * time.Second
|
||||
return bo
|
||||
}
|
||||
|
||||
// Use exponential backoff for each DB cleanup job so transient query failures are retried automatically rather than causing an immediate job failure
|
||||
return errors.Join(
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
|
||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
|
||||
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -13,20 +13,26 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
)
|
||||
|
||||
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
|
||||
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
|
||||
|
||||
err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
|
||||
var errs []error
|
||||
errs = append(errs,
|
||||
s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}),
|
||||
)
|
||||
|
||||
// Only necessary for file system storage
|
||||
if fileStorage.Type() == storage.TypeFileSystem {
|
||||
err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
|
||||
errs = append(errs,
|
||||
s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}),
|
||||
)
|
||||
}
|
||||
|
||||
return err
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
type FileCleanupJobs struct {
|
||||
@@ -68,7 +74,8 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
|
||||
// If these initials aren't used by any user, delete the file
|
||||
if _, ok := initialsInUse[initials]; !ok {
|
||||
filePath := path.Join(defaultPicturesDir, filename)
|
||||
if err := j.fileStorage.Delete(ctx, filePath); err != nil {
|
||||
err = j.fileStorage.Delete(ctx, filePath)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
|
||||
} else {
|
||||
filesDeleted++
|
||||
@@ -95,8 +102,9 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
|
||||
rErr := j.fileStorage.Delete(ctx, p.Path)
|
||||
if rErr != nil {
|
||||
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr))
|
||||
return nil
|
||||
}
|
||||
deleted++
|
||||
|
||||
@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
|
||||
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
|
||||
|
||||
// Run every 24 hours (and right away)
|
||||
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
|
||||
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, service.RegisterJobOpts{RunImmediately: true})
|
||||
}
|
||||
|
||||
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {
|
||||
|
||||
@@ -4,8 +4,6 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
@@ -17,8 +15,8 @@ type LdapJobs struct {
|
||||
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
|
||||
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
|
||||
|
||||
// Register the job to run every hour
|
||||
return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
|
||||
// Register the job to run every hour (with some jitter)
|
||||
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true})
|
||||
}
|
||||
|
||||
func (j *LdapJobs) syncLdap(ctx context.Context) error {
|
||||
|
||||
@@ -5,9 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
backoff "github.com/cenkalti/backoff/v5"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
type Scheduler struct {
|
||||
@@ -33,16 +37,12 @@ func (s *Scheduler) RemoveJob(name string) error {
|
||||
if job.Name() == name {
|
||||
err := s.scheduler.RemoveJob(job.ID())
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
|
||||
errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
return nil
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Run the scheduler.
|
||||
@@ -64,7 +64,29 @@ func (s *Scheduler) Run(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error {
|
||||
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error {
|
||||
// If a BackOff strategy is provided, wrap the job with retry logic
|
||||
if opts.BackOff != nil {
|
||||
origJob := jobFn
|
||||
jobFn = func(ctx context.Context) error {
|
||||
_, err := backoff.Retry(
|
||||
ctx,
|
||||
func() (struct{}, error) {
|
||||
return struct{}{}, origJob(ctx)
|
||||
},
|
||||
backoff.WithBackOff(opts.BackOff),
|
||||
backoff.WithNotify(func(err error, d time.Duration) {
|
||||
slog.WarnContext(ctx, "Job failed, retrying",
|
||||
slog.String("name", name),
|
||||
slog.Any("error", err),
|
||||
slog.Duration("retryIn", d),
|
||||
)
|
||||
}),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
jobOptions := []gocron.JobOption{
|
||||
gocron.WithContext(ctx),
|
||||
gocron.WithName(name),
|
||||
@@ -91,13 +113,13 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
|
||||
),
|
||||
}
|
||||
|
||||
if runImmediately {
|
||||
if opts.RunImmediately {
|
||||
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
|
||||
}
|
||||
|
||||
jobOptions = append(jobOptions, extraOptions...)
|
||||
jobOptions = append(jobOptions, opts.ExtraOptions...)
|
||||
|
||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
|
||||
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to register job %q: %w", name, err)
|
||||
@@ -105,3 +127,9 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
|
||||
const jitter = 5 * time.Minute
|
||||
|
||||
return gocron.DurationRandomJob(interval-jitter, interval+jitter)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ type ScimJobs struct {
|
||||
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
|
||||
jobs := &ScimJobs{scimService: scimService}
|
||||
|
||||
// Register the job to run every hour
|
||||
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
|
||||
// Register the job to run every hour (with some jitter)
|
||||
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true})
|
||||
}
|
||||
|
||||
func (j *ScimJobs) SyncScim(ctx context.Context) error {
|
||||
|
||||
@@ -74,10 +74,11 @@ func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
|
||||
|
||||
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||
userID, isAdmin, authenticationMethod, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Set("authenticationMethod", authenticationMethod)
|
||||
if c.IsAborted() {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService)
|
||||
|
||||
user := createUserForAuthMiddlewareTest(t, db)
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user)
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyToken, err := apiKeyService.CreateApiKey(t.Context(), user.ID, dto.ApiKeyCreateDto{
|
||||
|
||||
@@ -20,7 +20,7 @@ func NewJwtAuthMiddleware(jwtService *service.JwtService, userService *service.U
|
||||
|
||||
func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, isAdmin, err := m.Verify(c, adminRequired)
|
||||
userID, isAdmin, authenticationMethod, err := m.Verify(c, adminRequired)
|
||||
if err != nil {
|
||||
c.Abort()
|
||||
_ = c.Error(err)
|
||||
@@ -29,11 +29,12 @@ func (m *JwtAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
|
||||
|
||||
c.Set("userID", userID)
|
||||
c.Set("userIsAdmin", isAdmin)
|
||||
c.Set("authenticationMethod", authenticationMethod)
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, err error) {
|
||||
func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject string, isAdmin bool, authenticationMethod string, err error) {
|
||||
// Extract the token from the cookie
|
||||
accessToken, err := c.Cookie(cookie.AccessTokenCookieName)
|
||||
if err != nil {
|
||||
@@ -41,33 +42,37 @@ func (m *JwtAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (subject
|
||||
var ok bool
|
||||
_, accessToken, ok = strings.Cut(c.GetHeader("Authorization"), " ")
|
||||
if !ok || accessToken == "" {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
return "", false, "", &common.NotSignedInError{}
|
||||
}
|
||||
}
|
||||
|
||||
token, err := m.jwtService.VerifyAccessToken(accessToken)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
return "", false, "", &common.NotSignedInError{}
|
||||
}
|
||||
authenticationMethod, err = service.GetAuthenticationMethod(token)
|
||||
if err != nil {
|
||||
return "", false, "", &common.NotSignedInError{}
|
||||
}
|
||||
|
||||
subject, ok := token.Subject()
|
||||
if !ok {
|
||||
_ = c.Error(&common.TokenInvalidError{})
|
||||
return
|
||||
return "", false, "", &common.TokenInvalidError{}
|
||||
}
|
||||
|
||||
user, err := m.userService.GetUser(c, subject)
|
||||
if err != nil {
|
||||
return "", false, &common.NotSignedInError{}
|
||||
return "", false, "", &common.NotSignedInError{}
|
||||
}
|
||||
|
||||
if user.Disabled {
|
||||
return "", false, &common.UserDisabledError{}
|
||||
return "", false, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
if adminRequired && !user.IsAdmin {
|
||||
return "", false, &common.MissingPermissionError{}
|
||||
return "", false, "", &common.MissingPermissionError{}
|
||||
}
|
||||
|
||||
return subject, isAdmin, nil
|
||||
return subject, user.IsAdmin, authenticationMethod, nil
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type OidcAuthorizationCode struct {
|
||||
|
||||
Code string
|
||||
Scope string
|
||||
AuthenticationMethod string
|
||||
Nonce string
|
||||
CodeChallenge *string
|
||||
CodeChallengeMethodSha256 *bool
|
||||
@@ -77,9 +78,10 @@ func (c OidcClient) HasDarkLogo() bool {
|
||||
type OidcRefreshToken struct {
|
||||
Base
|
||||
|
||||
Token string
|
||||
ExpiresAt datatype.DateTime
|
||||
Scope string
|
||||
Token string
|
||||
ExpiresAt datatype.DateTime
|
||||
Scope string
|
||||
AuthenticationMethod string
|
||||
|
||||
UserID string
|
||||
User User
|
||||
@@ -141,12 +143,13 @@ func (cu UrlList) Value() (driver.Value, error) {
|
||||
|
||||
type OidcDeviceCode struct {
|
||||
Base
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Scope string
|
||||
Nonce string
|
||||
ExpiresAt datatype.DateTime
|
||||
IsAuthorized bool
|
||||
DeviceCode string
|
||||
UserCode string
|
||||
Scope string
|
||||
AuthenticationMethod string
|
||||
Nonce string
|
||||
ExpiresAt datatype.DateTime
|
||||
IsAuthorized bool
|
||||
|
||||
UserID *string
|
||||
User User
|
||||
|
||||
@@ -39,7 +39,7 @@ func (u User) WebAuthnDisplayName() string {
|
||||
if u.DisplayName != "" {
|
||||
return u.DisplayName
|
||||
}
|
||||
return u.FirstName + " " + u.LastName
|
||||
return u.FullName()
|
||||
}
|
||||
|
||||
func (u User) WebAuthnIcon() string { return "" }
|
||||
@@ -76,7 +76,16 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
|
||||
}
|
||||
|
||||
func (u User) FullName() string {
|
||||
return u.FirstName + " " + u.LastName
|
||||
fullname := strings.TrimSpace(u.FirstName + " " + u.LastName)
|
||||
if fullname != "" {
|
||||
return fullname
|
||||
}
|
||||
|
||||
if u.DisplayName != "" {
|
||||
return u.DisplayName
|
||||
}
|
||||
|
||||
return u.Username
|
||||
}
|
||||
|
||||
func (u User) Initials() string {
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -205,36 +206,33 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
|
||||
}
|
||||
|
||||
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
|
||||
user := apiKey.User
|
||||
|
||||
if user.ID == "" {
|
||||
if err := s.db.WithContext(ctx).First(&user, "id = ?", apiKey.UserID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if user.Email == nil {
|
||||
if apiKey.User.Email == nil {
|
||||
return &common.UserEmailNotSetError{}
|
||||
}
|
||||
|
||||
err := SendEmail(ctx, s.emailService, email.Address{
|
||||
Name: user.FullName(),
|
||||
Email: *user.Email,
|
||||
Name: apiKey.User.FullName(),
|
||||
Email: *apiKey.User.Email,
|
||||
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
|
||||
ApiKeyName: apiKey.Name,
|
||||
ExpiresAt: apiKey.ExpiresAt.ToTime(),
|
||||
Name: user.FirstName,
|
||||
Name: apiKey.User.FirstName,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
return fmt.Errorf("error sending notification email: %w", err)
|
||||
}
|
||||
|
||||
// Mark the API key as having had an expiration email sent
|
||||
return s.db.WithContext(ctx).
|
||||
err = s.db.WithContext(ctx).
|
||||
Model(&model.ApiKey{}).
|
||||
Where("id = ?", apiKey.ID).
|
||||
Update("expiration_email_sent", true).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("error recording expiration sent email in database: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ApiKeyService) initStaticApiKeyUser(ctx context.Context) (user model.User, err error) {
|
||||
|
||||
@@ -73,7 +73,10 @@ func (lv *lockValue) Unmarshal(raw string) error {
|
||||
// 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()
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error)
|
||||
}
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
@@ -93,7 +96,8 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
|
||||
|
||||
var prevLock lockValue
|
||||
if prevLockRaw != "" {
|
||||
if err := prevLock.Unmarshal(prevLockRaw); err != nil {
|
||||
err = prevLock.Unmarshal(prevLockRaw)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -139,7 +143,8 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
|
||||
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
|
||||
}
|
||||
|
||||
if err := tx.Commit().Error; err != nil {
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
|
||||
}
|
||||
|
||||
@@ -174,7 +179,8 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
if err := s.renew(ctx); err != nil {
|
||||
err := s.renew(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("renew lock: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -183,33 +189,43 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
|
||||
|
||||
// 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()
|
||||
db, err := s.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||
}
|
||||
|
||||
var query string
|
||||
switch s.db.Name() {
|
||||
case "sqlite":
|
||||
query = `
|
||||
DELETE FROM kv
|
||||
WHERE key = ?
|
||||
AND json_extract(value, '$.lock_id') = ?
|
||||
`
|
||||
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
|
||||
`
|
||||
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)
|
||||
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||
defer cancel()
|
||||
|
||||
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("release lock failed: %w", err)
|
||||
}
|
||||
|
||||
if res.RowsAffected == 0 {
|
||||
count, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||
}
|
||||
|
||||
if count == 0 {
|
||||
slog.Warn("Application lock not held by this process, cannot release",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
@@ -225,6 +241,11 @@ func (s *AppLockService) Release(ctx context.Context) error {
|
||||
|
||||
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
|
||||
func (s *AppLockService) renew(ctx context.Context) error {
|
||||
db, err := s.db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get DB connection: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= renewRetries; attempt++ {
|
||||
now := time.Now()
|
||||
@@ -246,42 +267,56 @@ func (s *AppLockService) renew(ctx context.Context) error {
|
||||
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') > ?
|
||||
`
|
||||
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)
|
||||
`
|
||||
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)
|
||||
res, err := db.ExecContext(opCtx, 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:
|
||||
// Query succeeded, but may have updated 0 rows
|
||||
if err == nil {
|
||||
count, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to count affected rows: %w", err)
|
||||
}
|
||||
|
||||
// If no rows were updated, we lost the lock
|
||||
if count == 0 {
|
||||
return ErrLockLost
|
||||
}
|
||||
|
||||
// All good
|
||||
slog.Debug("Renewed application lock",
|
||||
slog.Int64("process_id", s.processID),
|
||||
slog.String("host_id", s.hostID),
|
||||
slog.Duration("duration", time.Since(now)),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we're here, we have an error that can be retried
|
||||
slog.Debug("Application lock renewal attempt failed",
|
||||
slog.Any("error", err),
|
||||
slog.Duration("duration", time.Since(now)),
|
||||
)
|
||||
lastErr = fmt.Errorf("lock renewal failed: %w", err)
|
||||
|
||||
// Wait before next attempt or cancel if context is done
|
||||
if attempt < renewRetries {
|
||||
select {
|
||||
|
||||
@@ -49,6 +49,23 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue {
|
||||
return value
|
||||
}
|
||||
|
||||
func lockDatabaseForWrite(t *testing.T, db *gorm.DB) *gorm.DB {
|
||||
t.Helper()
|
||||
|
||||
tx := db.Begin()
|
||||
require.NoError(t, tx.Error)
|
||||
|
||||
// Keep a write transaction open to block other queries.
|
||||
err := tx.Exec(
|
||||
`INSERT INTO kv (key, value) VALUES (?, ?) ON CONFLICT(key) DO NOTHING`,
|
||||
lockKey,
|
||||
`{"expires_at":0}`,
|
||||
).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return tx
|
||||
}
|
||||
|
||||
func TestAppLockServiceAcquire(t *testing.T) {
|
||||
t.Run("creates new lock when none exists", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
@@ -99,6 +116,66 @@ func TestAppLockServiceAcquire(t *testing.T) {
|
||||
require.Equal(t, service.hostID, stored.HostID)
|
||||
require.Greater(t, stored.ExpiresAt, time.Now().Unix())
|
||||
})
|
||||
|
||||
t.Run("force acquisition returns wait duration when stealing active lock", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
existing := lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
LockID: "other-lock-id",
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
}
|
||||
insertLock(t, db, existing)
|
||||
|
||||
waitUntil, err := service.Acquire(context.Background(), true)
|
||||
require.NoError(t, err)
|
||||
require.WithinDuration(t, time.Unix(existing.ExpiresAt, 0), waitUntil, time.Second)
|
||||
})
|
||||
|
||||
t.Run("force acquisition does not wait when lock id is unchanged", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
insertLock(t, db, lockValue{
|
||||
ProcessID: 99,
|
||||
HostID: "other-host",
|
||||
LockID: service.lockID,
|
||||
ExpiresAt: time.Now().Add(ttl).Unix(),
|
||||
})
|
||||
|
||||
waitUntil, err := service.Acquire(context.Background(), true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, waitUntil.IsZero())
|
||||
})
|
||||
|
||||
t.Run("returns error when existing lock value is invalid JSON", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
raw := "this-is-not-json"
|
||||
err := db.Create(&model.KV{Key: lockKey, Value: &raw}).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = service.Acquire(context.Background(), false)
|
||||
require.ErrorContains(t, err, "decode existing lock value")
|
||||
})
|
||||
|
||||
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
tx := lockDatabaseForWrite(t, db)
|
||||
defer tx.Rollback()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
_, err := service.Acquire(ctx, false)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.ErrorContains(t, err, "begin lock transaction")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLockServiceRelease(t *testing.T) {
|
||||
@@ -134,6 +211,24 @@ func TestAppLockServiceRelease(t *testing.T) {
|
||||
stored := readLockValue(t, db)
|
||||
require.Equal(t, existing, stored)
|
||||
})
|
||||
|
||||
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
tx := lockDatabaseForWrite(t, db)
|
||||
defer tx.Rollback()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err = service.Release(ctx)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
require.ErrorContains(t, err, "release lock failed")
|
||||
})
|
||||
}
|
||||
|
||||
func TestAppLockServiceRenew(t *testing.T) {
|
||||
@@ -186,4 +281,21 @@ func TestAppLockServiceRenew(t *testing.T) {
|
||||
err = service.renew(context.Background())
|
||||
require.ErrorIs(t, err, ErrLockLost)
|
||||
})
|
||||
|
||||
t.Run("returns context deadline exceeded when database is locked", func(t *testing.T) {
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
service := newTestAppLockService(t, db)
|
||||
|
||||
_, err := service.Acquire(context.Background(), false)
|
||||
require.NoError(t, err)
|
||||
|
||||
tx := lockDatabaseForWrite(t, db)
|
||||
defer tx.Rollback()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err = service.renew(ctx)
|
||||
require.ErrorIs(t, err, context.DeadlineExceeded)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -245,20 +245,22 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
|
||||
authCodes := []model.OidcAuthorizationCode{
|
||||
{
|
||||
Code: "auth-code",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
Code: "auth-code",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
AuthenticationMethod: AuthenticationMethodPhishingResistant,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
},
|
||||
{
|
||||
Code: "federated",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[1].ID,
|
||||
ClientID: oidcClients[2].ID,
|
||||
Code: "federated",
|
||||
Scope: "openid profile",
|
||||
Nonce: "nonce",
|
||||
AuthenticationMethod: AuthenticationMethodPhishingResistant,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[1].ID,
|
||||
ClientID: oidcClients[3].ID,
|
||||
},
|
||||
}
|
||||
for _, authCode := range authCodes {
|
||||
@@ -268,11 +270,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
}
|
||||
|
||||
refreshToken := model.OidcRefreshToken{
|
||||
Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
Token: utils.CreateSha256Hash("ou87UDg249r1StBLYkMEqy9TXDbV5HmGuDpMcZDo"),
|
||||
AuthenticationMethod: AuthenticationMethodPhishingResistant,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
Scope: "openid profile email",
|
||||
UserID: users[0].ID,
|
||||
ClientID: oidcClients[0].ID,
|
||||
}
|
||||
if err := tx.Create(&refreshToken).Error; err != nil {
|
||||
return err
|
||||
|
||||
@@ -150,7 +150,8 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
|
||||
}
|
||||
|
||||
// Send the email
|
||||
if err := srv.sendEmailContent(client, toEmail, c); err != nil {
|
||||
err = srv.sendEmailContent(client, toEmail, c)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send email content: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -112,7 +113,11 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
|
||||
}
|
||||
|
||||
slog.Info("Updating GeoLite2 City database")
|
||||
downloadUrl := fmt.Sprintf(common.EnvConfig.GeoLiteDBUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||
|
||||
downloadUrl := common.EnvConfig.GeoLiteDBUrl
|
||||
if strings.Contains(downloadUrl, "%s") {
|
||||
downloadUrl = fmt.Sprintf(downloadUrl, common.EnvConfig.MaxMindLicenseKey)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
@@ -32,6 +32,15 @@ const (
|
||||
// RefreshTokenClaim is the claim used for the refresh token's value
|
||||
RefreshTokenClaim = "rt"
|
||||
|
||||
// AuthenticationMethodsClaim is the claim used to identify how the user authenticated
|
||||
AuthenticationMethodsClaim = "amr"
|
||||
|
||||
// AuthenticationMethodPhishingResistant identifies phishing-resistant authentication, such as passkeys
|
||||
AuthenticationMethodPhishingResistant = "phr"
|
||||
|
||||
// AuthenticationMethodOneTimePassword identifies one-time password/code authentication
|
||||
AuthenticationMethodOneTimePassword = "otp"
|
||||
|
||||
// OAuthAccessTokenJWTType identifies a JWT as an OAuth access token
|
||||
OAuthAccessTokenJWTType = "oauth-access-token" //nolint:gosec
|
||||
|
||||
@@ -187,7 +196,8 @@ func (s *JwtService) SetKey(privateKey jwk.Key) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
func (s *JwtService) GenerateAccessToken(user model.User, authenticationMethod string) (string, error) {
|
||||
|
||||
now := time.Now()
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
@@ -215,6 +225,11 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
return "", fmt.Errorf("failed to set 'isAdmin' claim in token: %w", err)
|
||||
}
|
||||
|
||||
err = SetAuthenticationMethods(token, authenticationMethod)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
|
||||
}
|
||||
|
||||
alg, _ := s.privateKey.Algorithm()
|
||||
signed, err := jwt.Sign(token, jwt.WithKey(alg, s.privateKey))
|
||||
if err != nil {
|
||||
@@ -243,7 +258,7 @@ func (s *JwtService) VerifyAccessToken(tokenString string) (jwt.Token, error) {
|
||||
}
|
||||
|
||||
// BuildIDToken creates an ID token with all claims
|
||||
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string) (jwt.Token, error) {
|
||||
func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (jwt.Token, error) {
|
||||
now := time.Now()
|
||||
token, err := jwt.NewBuilder().
|
||||
Expiration(now.Add(1 * time.Hour)).
|
||||
@@ -265,6 +280,11 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
|
||||
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||
}
|
||||
|
||||
err = SetAuthenticationMethods(token, authenticationMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
|
||||
}
|
||||
|
||||
for k, v := range userClaims {
|
||||
err = token.Set(k, v)
|
||||
if err != nil {
|
||||
@@ -283,8 +303,8 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
|
||||
}
|
||||
|
||||
// GenerateIDToken creates and signs an ID token
|
||||
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string) (string, error) {
|
||||
token, err := s.BuildIDToken(userClaims, clientID, nonce)
|
||||
func (s *JwtService) GenerateIDToken(userClaims map[string]any, clientID string, nonce string, authenticationMethod string) (string, error) {
|
||||
token, err := s.BuildIDToken(userClaims, clientID, nonce, authenticationMethod)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -332,7 +352,7 @@ func (s *JwtService) VerifyIdToken(tokenString string, acceptExpiredTokens bool)
|
||||
}
|
||||
|
||||
// BuildOAuthAccessToken creates an OAuth access token with all claims
|
||||
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jwt.Token, error) {
|
||||
func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (jwt.Token, error) {
|
||||
now := time.Now()
|
||||
token, err := jwt.NewBuilder().
|
||||
Subject(user.ID).
|
||||
@@ -355,12 +375,17 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
|
||||
return nil, fmt.Errorf("failed to set 'type' claim in token: %w", err)
|
||||
}
|
||||
|
||||
err = SetAuthenticationMethods(token, authenticationMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to set '%s' claim in token: %w", AuthenticationMethodsClaim, err)
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// GenerateOAuthAccessToken creates and signs an OAuth access token
|
||||
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string) (string, error) {
|
||||
token, err := s.BuildOAuthAccessToken(user, clientID)
|
||||
func (s *JwtService) GenerateOAuthAccessToken(user model.User, clientID string, authenticationMethod string) (string, error) {
|
||||
token, err := s.BuildOAuthAccessToken(user, clientID, authenticationMethod)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -534,6 +559,27 @@ func GetIsAdmin(token jwt.Token) (bool, error) {
|
||||
return isAdmin, nil
|
||||
}
|
||||
|
||||
// GetAuthenticationMethod returns the first authentication method in the "amr" claim in the token
|
||||
func GetAuthenticationMethod(token jwt.Token) (string, error) {
|
||||
if !token.Has(AuthenticationMethodsClaim) {
|
||||
return "", nil
|
||||
}
|
||||
var rawAuthenticationMethods []any
|
||||
err := token.Get(AuthenticationMethodsClaim, &rawAuthenticationMethods)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get '%s' claim from token: %w", AuthenticationMethodsClaim, err)
|
||||
}
|
||||
|
||||
if len(rawAuthenticationMethods) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
authenticationMethod, ok := rawAuthenticationMethods[0].(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("invalid '%s' claim in token: expected array of strings", AuthenticationMethodsClaim)
|
||||
}
|
||||
return authenticationMethod, nil
|
||||
}
|
||||
|
||||
// SetTokenType sets the "type" claim in the token
|
||||
func SetTokenType(token jwt.Token, tokenType string) error {
|
||||
if tokenType == "" {
|
||||
@@ -551,6 +597,14 @@ func SetIsAdmin(token jwt.Token, isAdmin bool) error {
|
||||
return token.Set(IsAdminClaim, isAdmin)
|
||||
}
|
||||
|
||||
// SetAuthenticationMethods sets the authentication method references claim in the token
|
||||
func SetAuthenticationMethods(token jwt.Token, authenticationMethod string) error {
|
||||
if authenticationMethod == "" {
|
||||
return nil
|
||||
}
|
||||
return token.Set(AuthenticationMethodsClaim, []string{authenticationMethod})
|
||||
}
|
||||
|
||||
// SetAudienceString sets the "aud" claim with a value that is a string, and not an array
|
||||
// This is permitted by RFC 7519, and it's done here for backwards-compatibility
|
||||
func SetAudienceString(token jwt.Token, audience string) error {
|
||||
|
||||
@@ -174,6 +174,7 @@ func TestJwtService_Init(t *testing.T) {
|
||||
_ = assert.True(t, ok) &&
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
func TestJwtService_GetPublicJWK(t *testing.T) {
|
||||
@@ -308,7 +309,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -321,6 +322,9 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
isAdmin, err := GetIsAdmin(claims)
|
||||
_ = assert.NoError(t, err, "Failed to get isAdmin claim") &&
|
||||
assert.False(t, isAdmin, "isAdmin should be false")
|
||||
authenticationMethod, err := GetAuthenticationMethod(claims)
|
||||
_ = assert.NoError(t, err, "Failed to get amr claim") &&
|
||||
assert.Empty(t, authenticationMethod, "amr should be empty when not specified")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{service.envConfig.AppURL}, audience, "Audience should contain the app URL")
|
||||
@@ -344,7 +348,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(adminUser)
|
||||
tokenString, err := service.GenerateAccessToken(adminUser, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
@@ -358,6 +362,24 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
assert.Equal(t, adminUser.ID, subject, "Token subject should match user ID")
|
||||
})
|
||||
|
||||
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user-with-auth-method"},
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
authenticationMethod, err := GetAuthenticationMethod(claims)
|
||||
_ = assert.NoError(t, err, "Failed to get amr claim") &&
|
||||
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
|
||||
})
|
||||
|
||||
t.Run("uses session duration from config", func(t *testing.T) {
|
||||
customMockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "30"}, // 30 minutes
|
||||
@@ -368,7 +390,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
Base: model.Base{ID: "user456"},
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token")
|
||||
|
||||
claims, err := service.VerifyAccessToken(tokenString)
|
||||
@@ -396,7 +418,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token with Ed25519 key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -433,7 +455,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token with ECDSA key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -470,7 +492,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
tokenString, err := service.GenerateAccessToken(user)
|
||||
tokenString, err := service.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err, "Failed to generate access token with RSA key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -508,7 +530,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -593,7 +615,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
const clientID = "test-client-456"
|
||||
nonce := "random-nonce-value"
|
||||
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce)
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, nonce, "")
|
||||
require.NoError(t, err, "Failed to generate ID token with nonce")
|
||||
|
||||
publicKey, err := service.GetPublicJWK()
|
||||
@@ -614,7 +636,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
userClaims := map[string]any{
|
||||
"sub": "user789",
|
||||
}
|
||||
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
|
||||
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token")
|
||||
|
||||
service.envConfig.AppURL = "https://wrong-issuer.com"
|
||||
@@ -640,7 +662,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "eddsa-client-123"
|
||||
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -677,7 +699,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "ecdsa-client-123"
|
||||
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -715,7 +737,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "rsa-client-123"
|
||||
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "")
|
||||
tokenString, err := service.GenerateIDToken(userClaims, clientID, "", "")
|
||||
require.NoError(t, err, "Failed to generate ID token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -745,7 +767,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -772,6 +794,25 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
assert.InDelta(t, 0, timeDiff, 1.0, "Token should expire in approximately 1 hour")
|
||||
})
|
||||
|
||||
t.Run("sets authentication method references claim when provided", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "oauth-amr-user"},
|
||||
}
|
||||
const clientID = "test-client-amr"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, AuthenticationMethodPhishingResistant)
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
|
||||
claims, err := service.VerifyOAuthAccessToken(tokenString)
|
||||
require.NoError(t, err, "Failed to verify generated OAuth access token")
|
||||
|
||||
authenticationMethod, err := GetAuthenticationMethod(claims)
|
||||
_ = assert.NoError(t, err, "Failed to get amr claim") &&
|
||||
assert.Equal(t, AuthenticationMethodPhishingResistant, authenticationMethod, "amr should match")
|
||||
})
|
||||
|
||||
t.Run("fails verification for expired token", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
@@ -805,7 +846,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
user := model.User{Base: model.Base{ID: "user789"}}
|
||||
const clientID = "test-client-789"
|
||||
|
||||
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID)
|
||||
tokenString, err := service1.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token")
|
||||
|
||||
_, err = service2.VerifyOAuthAccessToken(tokenString)
|
||||
@@ -828,7 +869,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "eddsa-oauth-client"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -865,7 +906,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "ecdsa-oauth-client"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
@@ -902,7 +943,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
}
|
||||
const clientID = "rsa-oauth-client"
|
||||
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID)
|
||||
tokenString, err := service.GenerateOAuthAccessToken(user, clientID, "")
|
||||
require.NoError(t, err, "Failed to generate OAuth access token with key")
|
||||
assert.NotEmpty(t, tokenString, "Token should not be empty")
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ type LdapService struct {
|
||||
userService *UserService
|
||||
groupService *UserGroupService
|
||||
fileStorage storage.FileStorage
|
||||
clientFactory func() (ldapClient, error)
|
||||
}
|
||||
|
||||
type savePicture struct {
|
||||
@@ -43,8 +44,33 @@ type savePicture struct {
|
||||
picture string
|
||||
}
|
||||
|
||||
type ldapDesiredUser struct {
|
||||
ldapID string
|
||||
input dto.UserCreateDto
|
||||
picture string
|
||||
}
|
||||
|
||||
type ldapDesiredGroup struct {
|
||||
ldapID string
|
||||
input dto.UserGroupCreateDto
|
||||
memberUsernames []string
|
||||
}
|
||||
|
||||
type ldapDesiredState struct {
|
||||
users []ldapDesiredUser
|
||||
userIDs map[string]struct{}
|
||||
groups []ldapDesiredGroup
|
||||
groupIDs map[string]struct{}
|
||||
}
|
||||
|
||||
type ldapClient interface {
|
||||
Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
Bind(username, password string) error
|
||||
Close() error
|
||||
}
|
||||
|
||||
func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
|
||||
return &LdapService{
|
||||
service := &LdapService{
|
||||
db: db,
|
||||
httpClient: httpClient,
|
||||
appConfigService: appConfigService,
|
||||
@@ -52,9 +78,12 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
|
||||
groupService: groupService,
|
||||
fileStorage: fileStorage,
|
||||
}
|
||||
|
||||
service.clientFactory = service.createClient
|
||||
return service
|
||||
}
|
||||
|
||||
func (s *LdapService) createClient() (*ldap.Conn, error) {
|
||||
func (s *LdapService) createClient() (ldapClient, error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
if !dbConfig.LdapEnabled.IsTrue() {
|
||||
@@ -79,24 +108,33 @@ func (s *LdapService) createClient() (*ldap.Conn, error) {
|
||||
|
||||
func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
// Setup LDAP connection
|
||||
client, err := s.createClient()
|
||||
client, err := s.clientFactory()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create LDAP client: %w", err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Start a transaction
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
// First, we fetch all users and group from LDAP, which is our "desired state"
|
||||
desiredState, err := s.fetchDesiredState(ctx, client)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch LDAP state: %w", err)
|
||||
}
|
||||
|
||||
savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
|
||||
// Start a transaction
|
||||
tx := s.db.WithContext(ctx).Begin()
|
||||
if tx.Error != nil {
|
||||
return fmt.Errorf("failed to begin database transaction: %w", tx.Error)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Reconcile users
|
||||
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users: %w", err)
|
||||
}
|
||||
|
||||
err = s.SyncGroups(ctx, tx, client)
|
||||
// Reconcile groups
|
||||
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync groups: %w", err)
|
||||
}
|
||||
@@ -129,10 +167,59 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:gocognit
|
||||
func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
|
||||
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) {
|
||||
// Fetch users first so we can use their DNs when resolving group members
|
||||
users, userIDs, usernamesByDN, err := s.fetchUsersFromLDAP(ctx, client)
|
||||
if err != nil {
|
||||
return ldapDesiredState{}, err
|
||||
}
|
||||
|
||||
// Then fetch groups to complete the desired LDAP state snapshot
|
||||
groups, groupIDs, err := s.fetchGroupsFromLDAP(ctx, client, usernamesByDN)
|
||||
if err != nil {
|
||||
return ldapDesiredState{}, err
|
||||
}
|
||||
|
||||
// Apply user admin flags from the desired group membership snapshot.
|
||||
// This intentionally uses the configured group member attribute rather than
|
||||
// relying on a user-side reverse-membership attribute such as memberOf.
|
||||
s.applyAdminGroupMembership(users, groups)
|
||||
|
||||
return ldapDesiredState{
|
||||
users: users,
|
||||
userIDs: userIDs,
|
||||
groups: groups,
|
||||
groupIDs: groupIDs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) applyAdminGroupMembership(desiredUsers []ldapDesiredUser, desiredGroups []ldapDesiredGroup) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
if dbConfig.LdapAdminGroupName.Value == "" {
|
||||
return
|
||||
}
|
||||
|
||||
adminUsernames := make(map[string]struct{})
|
||||
for _, group := range desiredGroups {
|
||||
if group.input.Name != dbConfig.LdapAdminGroupName.Value {
|
||||
continue
|
||||
}
|
||||
|
||||
for _, username := range group.memberUsernames {
|
||||
adminUsernames[username] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
for i := range desiredUsers {
|
||||
_, isAdmin := adminUsernames[desiredUsers[i].input.Username]
|
||||
desiredUsers[i].input.IsAdmin = desiredUsers[i].input.IsAdmin || isAdmin
|
||||
}
|
||||
}
|
||||
|
||||
func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient, usernamesByDN map[string]string) (desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Query LDAP for all groups we want to manage
|
||||
searchAttrs := []string{
|
||||
dbConfig.LdapAttributeGroupName.Value,
|
||||
dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
|
||||
@@ -149,90 +236,42 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
||||
)
|
||||
result, err := client.Search(searchReq)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to query LDAP: %w", err)
|
||||
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err)
|
||||
}
|
||||
|
||||
// Create a mapping for groups that exist
|
||||
ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
|
||||
// Build the in-memory desired state for groups
|
||||
ldapGroupIDs = make(map[string]struct{}, len(result.Entries))
|
||||
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
|
||||
// Skip groups without a valid LDAP ID
|
||||
if ldapId == "" {
|
||||
if ldapID == "" {
|
||||
slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
ldapGroupIDs[ldapId] = struct{}{}
|
||||
|
||||
// Try to find the group in the database
|
||||
var databaseGroup model.UserGroup
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("ldap_id = ?", ldapId).
|
||||
First(&databaseGroup).
|
||||
Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// This could error with ErrRecordNotFound and we want to ignore that here
|
||||
return fmt.Errorf("failed to query for LDAP group ID '%s': %w", ldapId, err)
|
||||
}
|
||||
ldapGroupIDs[ldapID] = struct{}{}
|
||||
|
||||
// Get group members and add to the correct Group
|
||||
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
|
||||
membersUserId := make([]string, 0, len(groupMembers))
|
||||
memberUsernames := make([]string, 0, len(groupMembers))
|
||||
for _, member := range groupMembers {
|
||||
username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||
|
||||
// If username extraction fails, try to query LDAP directly for the user
|
||||
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN)
|
||||
if username == "" {
|
||||
// Query LDAP to get the user by their DN
|
||||
userSearchReq := ldap.NewSearchRequest(
|
||||
member,
|
||||
ldap.ScopeBaseObject,
|
||||
0, 0, 0, false,
|
||||
"(objectClass=*)",
|
||||
[]string{dbConfig.LdapAttributeUserUsername.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value},
|
||||
[]ldap.Control{},
|
||||
)
|
||||
|
||||
userResult, err := client.Search(userSearchReq)
|
||||
if err != nil || len(userResult.Entries) == 0 {
|
||||
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
||||
continue
|
||||
}
|
||||
|
||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||
if username == "" {
|
||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
username = norm.NFC.String(username)
|
||||
|
||||
var databaseUser model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("username = ? AND ldap_id IS NOT NULL", username).
|
||||
First(&databaseUser).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// The user collides with a non-LDAP user, so we skip it
|
||||
continue
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to query for existing user '%s': %w", username, err)
|
||||
}
|
||||
|
||||
membersUserId = append(membersUserId, databaseUser.ID)
|
||||
memberUsernames = append(memberUsernames, username)
|
||||
}
|
||||
|
||||
syncGroup := dto.UserGroupCreateDto{
|
||||
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
|
||||
LdapID: ldapId,
|
||||
LdapID: ldapID,
|
||||
}
|
||||
dto.Normalize(syncGroup)
|
||||
dto.Normalize(&syncGroup)
|
||||
|
||||
err = syncGroup.Validate()
|
||||
if err != nil {
|
||||
@@ -240,66 +279,21 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
|
||||
continue
|
||||
}
|
||||
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, membersUserId, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
} else {
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, syncGroup, true, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, membersUserId, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", syncGroup.Name, err)
|
||||
}
|
||||
}
|
||||
desiredGroups = append(desiredGroups, ldapDesiredGroup{
|
||||
ldapID: ldapID,
|
||||
input: syncGroup,
|
||||
memberUsernames: memberUsernames,
|
||||
})
|
||||
}
|
||||
|
||||
// Get all LDAP groups from the database
|
||||
var ldapGroupsInDb []model.UserGroup
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Find(&ldapGroupsInDb, "ldap_id IS NOT NULL").
|
||||
Select("ldap_id").
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch groups from database: %w", err)
|
||||
}
|
||||
|
||||
// Delete groups that no longer exist in LDAP
|
||||
for _, group := range ldapGroupsInDb {
|
||||
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Delete(&model.UserGroup{}, "ldap_id = ?", group.LdapID).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
||||
}
|
||||
|
||||
slog.Info("Deleted group", slog.String("group", group.Name))
|
||||
}
|
||||
|
||||
return nil
|
||||
return desiredGroups, ldapGroupIDs, nil
|
||||
}
|
||||
|
||||
//nolint:gocognit
|
||||
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Query LDAP for all users we want to manage
|
||||
searchAttrs := []string{
|
||||
"memberOf",
|
||||
"sn",
|
||||
"cn",
|
||||
dbConfig.LdapAttributeUserUniqueIdentifier.Value,
|
||||
@@ -323,59 +317,29 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
|
||||
result, err := client.Search(searchReq)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to query LDAP: %w", err)
|
||||
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err)
|
||||
}
|
||||
|
||||
// Create a mapping for users that exist
|
||||
ldapUserIDs := make(map[string]struct{}, len(result.Entries))
|
||||
savePictures = make([]savePicture, 0, len(result.Entries))
|
||||
// Build the in-memory desired state for users and a DN lookup for group membership resolution
|
||||
ldapUserIDs = make(map[string]struct{}, len(result.Entries))
|
||||
usernamesByDN = make(map[string]string, len(result.Entries))
|
||||
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
|
||||
|
||||
for _, value := range result.Entries {
|
||||
ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value))
|
||||
if normalizedDN := normalizeLDAPDN(value.DN); normalizedDN != "" && username != "" {
|
||||
usernamesByDN[normalizedDN] = username
|
||||
}
|
||||
|
||||
ldapID := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
|
||||
// Skip users without a valid LDAP ID
|
||||
if ldapId == "" {
|
||||
if ldapID == "" {
|
||||
slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
|
||||
continue
|
||||
}
|
||||
|
||||
ldapUserIDs[ldapId] = struct{}{}
|
||||
|
||||
// Get the user from the database
|
||||
var databaseUser model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("ldap_id = ?", ldapId).
|
||||
First(&databaseUser).
|
||||
Error
|
||||
|
||||
// If a user is found (even if disabled), enable them since they're now back in LDAP
|
||||
if databaseUser.ID != "" && databaseUser.Disabled {
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ?", databaseUser.ID).
|
||||
Update("disabled", false).
|
||||
Error
|
||||
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// This could error with ErrRecordNotFound and we want to ignore that here
|
||||
return nil, nil, fmt.Errorf("failed to query for LDAP user ID '%s': %w", ldapId, err)
|
||||
}
|
||||
|
||||
// Check if user is admin by checking if they are in the admin group
|
||||
isAdmin := false
|
||||
for _, group := range value.GetAttributeValues("memberOf") {
|
||||
if getDNProperty(dbConfig.LdapAttributeGroupName.Value, group) == dbConfig.LdapAdminGroupName.Value {
|
||||
isAdmin = true
|
||||
break
|
||||
}
|
||||
}
|
||||
ldapUserIDs[ldapID] = struct{}{}
|
||||
|
||||
newUser := dto.UserCreateDto{
|
||||
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
|
||||
@@ -384,15 +348,17 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
|
||||
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
|
||||
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
|
||||
IsAdmin: isAdmin,
|
||||
LdapID: ldapId,
|
||||
// Admin status is computed after groups are loaded so it can use the
|
||||
// configured group member attribute instead of a hard-coded memberOf.
|
||||
IsAdmin: false,
|
||||
LdapID: ldapID,
|
||||
}
|
||||
|
||||
if newUser.DisplayName == "" {
|
||||
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
|
||||
}
|
||||
|
||||
dto.Normalize(newUser)
|
||||
dto.Normalize(&newUser)
|
||||
|
||||
err = newUser.Validate()
|
||||
if err != nil {
|
||||
@@ -400,53 +366,207 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
continue
|
||||
}
|
||||
|
||||
userID := databaseUser.ID
|
||||
if databaseUser.ID == "" {
|
||||
createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
desiredUsers = append(desiredUsers, ldapDesiredUser{
|
||||
ldapID: ldapID,
|
||||
input: newUser,
|
||||
picture: value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value),
|
||||
})
|
||||
}
|
||||
|
||||
return desiredUsers, ldapUserIDs, usernamesByDN, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) resolveGroupMemberUsername(ctx context.Context, client ldapClient, member string, usernamesByDN map[string]string) string {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// First try the DN cache we built while loading users
|
||||
username, exists := usernamesByDN[normalizeLDAPDN(member)]
|
||||
if exists && username != "" {
|
||||
return username
|
||||
}
|
||||
|
||||
// Then try to extract the username directly from the DN
|
||||
username = getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
|
||||
if username != "" {
|
||||
return norm.NFC.String(username)
|
||||
}
|
||||
|
||||
// posixGroup (and similar) stores bare usernames in memberUid, not DNs. Treat any value
|
||||
// that is not a valid DN as the username directly — see https://github.com/pocket-id/pocket-id/issues/1408
|
||||
if _, err := ldap.ParseDN(member); err != nil {
|
||||
return norm.NFC.String(member)
|
||||
}
|
||||
|
||||
// As a fallback, query LDAP for the referenced entry
|
||||
userSearchReq := ldap.NewSearchRequest(
|
||||
member,
|
||||
ldap.ScopeBaseObject,
|
||||
0, 0, 0, false,
|
||||
"(objectClass=*)",
|
||||
[]string{dbConfig.LdapAttributeUserUsername.Value},
|
||||
[]ldap.Control{},
|
||||
)
|
||||
|
||||
userResult, err := client.Search(userSearchReq)
|
||||
if err != nil || len(userResult.Entries) == 0 {
|
||||
slog.WarnContext(ctx, "Could not resolve group member DN", slog.String("member", member), slog.Any("error", err))
|
||||
return ""
|
||||
}
|
||||
|
||||
username = userResult.Entries[0].GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)
|
||||
if username == "" {
|
||||
slog.WarnContext(ctx, "Could not extract username from group member DN", slog.String("member", member))
|
||||
return ""
|
||||
}
|
||||
|
||||
return norm.NFC.String(username)
|
||||
}
|
||||
|
||||
func (s *LdapService) reconcileGroups(ctx context.Context, tx *gorm.DB, desiredGroups []ldapDesiredGroup, ldapGroupIDs map[string]struct{}) error {
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapGroupsInDB, ldapGroupsByID, err := s.loadLDAPGroupsInDB(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch groups from database: %w", err)
|
||||
}
|
||||
|
||||
_, _, ldapUsersByUsername, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch users from database: %w", err)
|
||||
}
|
||||
|
||||
// Apply creates and updates to match the desired LDAP group state
|
||||
for _, desiredGroup := range desiredGroups {
|
||||
memberUserIDs := make([]string, 0, len(desiredGroup.memberUsernames))
|
||||
for _, username := range desiredGroup.memberUsernames {
|
||||
databaseUser, exists := ldapUsersByUsername[username]
|
||||
if !exists {
|
||||
// The user collides with a non-LDAP user or was skipped during user sync, so we ignore it
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
|
||||
}
|
||||
userID = createdUser.ID
|
||||
} else {
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
|
||||
}
|
||||
|
||||
memberUserIDs = append(memberUserIDs, databaseUser.ID)
|
||||
}
|
||||
|
||||
// Save profile picture
|
||||
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
|
||||
if pictureString != "" {
|
||||
// Storage operations must be executed outside of a transaction
|
||||
savePictures = append(savePictures, savePicture{
|
||||
userID: databaseUser.ID,
|
||||
username: userID,
|
||||
picture: pictureString,
|
||||
})
|
||||
databaseGroup := ldapGroupsByID[desiredGroup.ldapID]
|
||||
if databaseGroup.ID == "" {
|
||||
newGroup, err := s.groupService.createInternal(ctx, desiredGroup.input, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
ldapGroupsByID[desiredGroup.ldapID] = newGroup
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, newGroup.ID, memberUserIDs, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateInternal(ctx, databaseGroup.ID, desiredGroup.input, true, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
|
||||
_, err = s.groupService.updateUsersInternal(ctx, databaseGroup.ID, memberUserIDs, tx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to sync users for group '%s': %w", desiredGroup.input.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Get all LDAP users from the database
|
||||
var ldapUsersInDb []model.User
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
|
||||
Select("id, username, ldap_id, disabled").
|
||||
Error
|
||||
// Delete groups that are no longer present in LDAP
|
||||
for _, group := range ldapGroupsInDB {
|
||||
if group.LdapID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := ldapGroupIDs[*group.LdapID]; exists {
|
||||
continue
|
||||
}
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Delete(&model.UserGroup{}, "ldap_id = ?", *group.LdapID).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete group '%s': %w", group.Name, err)
|
||||
}
|
||||
|
||||
slog.Info("Deleted group", slog.String("group", group.Name))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:gocognit
|
||||
func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}) (savePictures []savePicture, deleteFiles []string, err error) {
|
||||
dbConfig := s.appConfigService.GetDbConfig()
|
||||
|
||||
// Load the current LDAP-managed state from the database
|
||||
ldapUsersInDB, ldapUsersByID, _, err := s.loadLDAPUsersInDB(ctx, tx)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to fetch users from database: %w", err)
|
||||
}
|
||||
|
||||
// Mark users as disabled or delete users that no longer exist in LDAP
|
||||
deleteFiles = make([]string, 0, len(ldapUserIDs))
|
||||
for _, user := range ldapUsersInDb {
|
||||
// Skip if the user ID exists in the fetched LDAP results
|
||||
// Apply creates and updates to match the desired LDAP user state
|
||||
savePictures = make([]savePicture, 0, len(desiredUsers))
|
||||
|
||||
for _, desiredUser := range desiredUsers {
|
||||
databaseUser := ldapUsersByID[desiredUser.ldapID]
|
||||
|
||||
// If a user is found (even if disabled), enable them since they're now back in LDAP.
|
||||
if databaseUser.ID != "" && databaseUser.Disabled {
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ?", databaseUser.ID).
|
||||
Update("disabled", false).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to enable user %s: %w", databaseUser.Username, err)
|
||||
}
|
||||
|
||||
databaseUser.Disabled = false
|
||||
ldapUsersByID[desiredUser.ldapID] = databaseUser
|
||||
}
|
||||
|
||||
userID := databaseUser.ID
|
||||
if databaseUser.ID == "" {
|
||||
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err)
|
||||
}
|
||||
|
||||
userID = createdUser.ID
|
||||
ldapUsersByID[desiredUser.ldapID] = createdUser
|
||||
} else {
|
||||
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx)
|
||||
if errors.Is(err, &common.AlreadyInUseError{}) {
|
||||
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err))
|
||||
continue
|
||||
} else if err != nil {
|
||||
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err)
|
||||
}
|
||||
}
|
||||
|
||||
if desiredUser.picture != "" {
|
||||
savePictures = append(savePictures, savePicture{
|
||||
userID: userID,
|
||||
username: desiredUser.input.Username,
|
||||
picture: desiredUser.picture,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Disable or delete users that are no longer present in LDAP
|
||||
deleteFiles = make([]string, 0, len(ldapUsersInDB))
|
||||
for _, user := range ldapUsersInDB {
|
||||
if user.LdapID == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
if _, exists := ldapUserIDs[*user.LdapID]; exists {
|
||||
continue
|
||||
}
|
||||
@@ -458,29 +578,73 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
|
||||
}
|
||||
|
||||
slog.Info("Disabled user", slog.String("username", user.Username))
|
||||
} else {
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||
if err != nil {
|
||||
target := &common.LdapUserUpdateError{}
|
||||
if errors.As(err, &target) {
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
||||
}
|
||||
|
||||
slog.Info("Deleted user", slog.String("username", user.Username))
|
||||
|
||||
// Storage operations must be executed outside of a transaction
|
||||
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
|
||||
continue
|
||||
}
|
||||
|
||||
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
|
||||
if err != nil {
|
||||
target := &common.LdapUserUpdateError{}
|
||||
if errors.As(err, &target) {
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
|
||||
}
|
||||
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
|
||||
}
|
||||
|
||||
slog.Info("Deleted user", slog.String("username", user.Username))
|
||||
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
|
||||
}
|
||||
|
||||
return savePictures, deleteFiles, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) loadLDAPUsersInDB(ctx context.Context, tx *gorm.DB) (users []model.User, byLdapID map[string]model.User, byUsername map[string]model.User, err error) {
|
||||
// Load all LDAP-managed users and index them by LDAP ID and by username
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Select("id, username, ldap_id, disabled").
|
||||
Where("ldap_id IS NOT NULL").
|
||||
Find(&users).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
|
||||
byLdapID = make(map[string]model.User, len(users))
|
||||
byUsername = make(map[string]model.User, len(users))
|
||||
for _, user := range users {
|
||||
byLdapID[*user.LdapID] = user
|
||||
byUsername[user.Username] = user
|
||||
}
|
||||
|
||||
return users, byLdapID, byUsername, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) loadLDAPGroupsInDB(ctx context.Context, tx *gorm.DB) ([]model.UserGroup, map[string]model.UserGroup, error) {
|
||||
var groups []model.UserGroup
|
||||
|
||||
// Load all LDAP-managed groups and index them by LDAP ID
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
Select("id, name, ldap_id").
|
||||
Where("ldap_id IS NOT NULL").
|
||||
Find(&groups).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
groupsByID := make(map[string]model.UserGroup, len(groups))
|
||||
for _, group := range groups {
|
||||
groupsByID[*group.LdapID] = group
|
||||
}
|
||||
|
||||
return groups, groupsByID, nil
|
||||
}
|
||||
|
||||
func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
|
||||
var reader io.ReadSeeker
|
||||
|
||||
// Accept either a URL, a base64-encoded payload, or raw binary data
|
||||
_, err := url.ParseRequestURI(pictureString)
|
||||
if err == nil {
|
||||
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
|
||||
@@ -522,6 +686,31 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizeLDAPDN returns a canonical lowercase form of a DN for use as a map key.
|
||||
// Different LDAP servers may format the same DN with varying attribute type casing (e.g. "CN=" vs "cn=") or extra whitespace (e.g. "dc=example, dc=com").
|
||||
// Without normalization, cache lookups in usernamesByDN would miss when a member attribute value uses a different format than the DN returned in the search entry
|
||||
//
|
||||
// ldap.ParseDN is used instead of simple lowercasing because it correctly handles multi-valued RDNs (joined with "+") and strips inter-component whitespace.
|
||||
// If parsing fails for any reason, we fall back to a simple lowercase+trim.
|
||||
func normalizeLDAPDN(dn string) string {
|
||||
parsed, err := ldap.ParseDN(dn)
|
||||
if err != nil {
|
||||
return strings.ToLower(strings.TrimSpace(dn))
|
||||
}
|
||||
|
||||
// Reconstruct the DN in a canonical form: lowercase type=lowercase value, with RDN components separated by "," and multi-value attributes by "+"
|
||||
parts := make([]string, 0, len(parsed.RDNs))
|
||||
for _, rdn := range parsed.RDNs {
|
||||
attrs := make([]string, 0, len(rdn.Attributes))
|
||||
for _, attr := range rdn.Attributes {
|
||||
attrs = append(attrs, strings.ToLower(attr.Type)+"="+strings.ToLower(attr.Value))
|
||||
}
|
||||
parts = append(parts, strings.Join(attrs, "+"))
|
||||
}
|
||||
|
||||
return strings.Join(parts, ",")
|
||||
}
|
||||
|
||||
// getDNProperty returns the value of a property from a LDAP identifier
|
||||
// See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
|
||||
func getDNProperty(property string, str string) string {
|
||||
|
||||
@@ -1,9 +1,410 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/go-ldap/ldap/v3"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
type fakeLDAPClient struct {
|
||||
searchFn func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error)
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Search(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
if c.searchFn == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return c.searchFn(searchRequest)
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Bind(_, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fakeLDAPClient) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllReconcilesUsersAndGroups(t *testing.T) {
|
||||
service, db := newTestLdapService(t, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-alice"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alice"},
|
||||
"sn": {"Jones"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
ldapEntry("uid=bob,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-bob"},
|
||||
"uid": {"bob"},
|
||||
"mail": {"bob@example.com"},
|
||||
"givenName": {"Bob"},
|
||||
"sn": {"Brown"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(
|
||||
ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-admins"},
|
||||
"cn": {"admins"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-team"},
|
||||
"cn": {"team"},
|
||||
"member": {
|
||||
"UID=Alice, OU=People, DC=example, DC=com",
|
||||
"uid=bob, ou=people, dc=example, dc=com",
|
||||
},
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
aliceLdapID := "u-alice"
|
||||
missingLdapID := "u-missing"
|
||||
teamLdapID := "g-team"
|
||||
oldGroupLdapID := "g-old"
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Username: "alice-old",
|
||||
Email: new("alice-old@example.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Old",
|
||||
LastName: "Name",
|
||||
DisplayName: "Old Name",
|
||||
LdapID: &aliceLdapID,
|
||||
Disabled: true,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Create(&model.User{
|
||||
Username: "missing",
|
||||
Email: new("missing@example.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Missing",
|
||||
LastName: "User",
|
||||
DisplayName: "Missing User",
|
||||
LdapID: &missingLdapID,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Create(&model.UserGroup{
|
||||
Name: "team-old",
|
||||
FriendlyName: "team-old",
|
||||
LdapID: &teamLdapID,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, db.Create(&model.UserGroup{
|
||||
Name: "old-group",
|
||||
FriendlyName: "old-group",
|
||||
LdapID: &oldGroupLdapID,
|
||||
}).Error)
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var alice model.User
|
||||
require.NoError(t, db.First(&alice, "ldap_id = ?", aliceLdapID).Error)
|
||||
assert.Equal(t, "alice", alice.Username)
|
||||
assert.Equal(t, new("alice@example.com"), alice.Email)
|
||||
assert.Equal(t, "Alice", alice.FirstName)
|
||||
assert.Equal(t, "Jones", alice.LastName)
|
||||
assert.Equal(t, "Alice Jones", alice.DisplayName)
|
||||
assert.True(t, alice.IsAdmin)
|
||||
assert.False(t, alice.Disabled)
|
||||
|
||||
var bob model.User
|
||||
require.NoError(t, db.First(&bob, "ldap_id = ?", "u-bob").Error)
|
||||
assert.Equal(t, "bob", bob.Username)
|
||||
assert.Equal(t, "Bob Brown", bob.DisplayName)
|
||||
|
||||
var missing model.User
|
||||
require.NoError(t, db.First(&missing, "ldap_id = ?", missingLdapID).Error)
|
||||
assert.True(t, missing.Disabled)
|
||||
|
||||
var oldGroupCount int64
|
||||
require.NoError(t, db.Model(&model.UserGroup{}).Where("ldap_id = ?", oldGroupLdapID).Count(&oldGroupCount).Error)
|
||||
assert.Zero(t, oldGroupCount)
|
||||
|
||||
var team model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").First(&team, "ldap_id = ?", teamLdapID).Error)
|
||||
assert.Equal(t, "team", team.Name)
|
||||
assert.Equal(t, "team", team.FriendlyName)
|
||||
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(team.Users))
|
||||
}
|
||||
|
||||
// Regression: posixGroup uses memberUid (bare uid values), not member DNs — issue #1408.
|
||||
func TestLdapServiceSyncAllMapsPosixGroupMemberUid(t *testing.T) {
|
||||
appCfg := defaultTestLDAPAppConfig()
|
||||
appCfg.LdapUserGroupSearchFilter = model.AppConfigVariable{Value: "(objectClass=posixGroup)"}
|
||||
appCfg.LdapAttributeGroupMember = model.AppConfigVariable{Value: "memberUid"}
|
||||
|
||||
service, db := newTestLdapServiceWithAppConfig(t, appCfg, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=alice,ou=users,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-alice"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alice"},
|
||||
"sn": {"Jones"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
ldapEntry("uid=bob,ou=users,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-bob"},
|
||||
"uid": {"bob"},
|
||||
"mail": {"bob@example.com"},
|
||||
"givenName": {"Bob"},
|
||||
"sn": {"Brown"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(
|
||||
ldapEntry("cn=users,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-users"},
|
||||
"cn": {"users"},
|
||||
"memberUid": {"alice", "bob", "unknown"},
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var group model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", "g-users").Error)
|
||||
assert.Equal(t, "users", group.Name)
|
||||
assert.ElementsMatch(t, []string{"alice", "bob"}, usernames(group.Users))
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllHandlesDuplicateLDAPIDsInSingleRun(t *testing.T) {
|
||||
service, db := newTestLdapService(t, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-dup"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alice"},
|
||||
"sn": {"Doe"},
|
||||
"displayName": {"Alice Doe"},
|
||||
}),
|
||||
ldapEntry("uid=alice,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-dup"},
|
||||
"uid": {"alice"},
|
||||
"mail": {"alice@example.com"},
|
||||
"givenName": {"Alicia"},
|
||||
"sn": {"Doe"},
|
||||
"displayName": {"Alicia Doe"},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-dup"},
|
||||
"cn": {"team"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
ldapEntry("cn=team,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-dup"},
|
||||
"cn": {"team-renamed"},
|
||||
"member": {"uid=alice,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
),
|
||||
))
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var users []model.User
|
||||
require.NoError(t, db.Find(&users, "ldap_id = ?", "u-dup").Error)
|
||||
require.Len(t, users, 1)
|
||||
assert.Equal(t, "alice", users[0].Username)
|
||||
assert.Equal(t, "Alicia", users[0].FirstName)
|
||||
assert.Equal(t, "Alicia Doe", users[0].DisplayName)
|
||||
|
||||
var groups []model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").Find(&groups, "ldap_id = ?", "g-dup").Error)
|
||||
require.Len(t, groups, 1)
|
||||
assert.Equal(t, "team-renamed", groups[0].Name)
|
||||
assert.Equal(t, "team-renamed", groups[0].FriendlyName)
|
||||
assert.ElementsMatch(t, []string{"alice"}, usernames(groups[0].Users))
|
||||
}
|
||||
|
||||
func TestLdapServiceSyncAllSetsAdminFromGroupMembership(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
appConfig *model.AppConfig
|
||||
groupEntry *ldap.Entry
|
||||
groupName string
|
||||
groupLookup string
|
||||
}{
|
||||
{
|
||||
name: "memberOf missing on user",
|
||||
appConfig: defaultTestLDAPAppConfig(),
|
||||
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-admins"},
|
||||
"cn": {"admins"},
|
||||
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
groupName: "admins",
|
||||
groupLookup: "g-admins",
|
||||
},
|
||||
{
|
||||
name: "configured group name attribute differs from DN RDN",
|
||||
appConfig: func() *model.AppConfig {
|
||||
cfg := defaultTestLDAPAppConfig()
|
||||
cfg.LdapAttributeGroupName = model.AppConfigVariable{Value: "displayName"}
|
||||
cfg.LdapAdminGroupName = model.AppConfigVariable{Value: "pocketid.admin"}
|
||||
return cfg
|
||||
}(),
|
||||
groupEntry: ldapEntry("cn=admins,ou=groups,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"g-display-admins"},
|
||||
"cn": {"admins"},
|
||||
"displayName": {"pocketid.admin"},
|
||||
"member": {"uid=testadmin,ou=people,dc=example,dc=com"},
|
||||
}),
|
||||
groupName: "pocketid.admin",
|
||||
groupLookup: "g-display-admins",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
service, db := newTestLdapServiceWithAppConfig(t, tt.appConfig, newFakeLDAPClient(
|
||||
ldapSearchResult(
|
||||
ldapEntry("uid=testadmin,ou=people,dc=example,dc=com", map[string][]string{
|
||||
"entryUUID": {"u-testadmin"},
|
||||
"uid": {"testadmin"},
|
||||
"mail": {"testadmin@example.com"},
|
||||
"givenName": {"Test"},
|
||||
"sn": {"Admin"},
|
||||
"displayName": {""},
|
||||
}),
|
||||
),
|
||||
ldapSearchResult(tt.groupEntry),
|
||||
))
|
||||
|
||||
require.NoError(t, service.SyncAll(t.Context()))
|
||||
|
||||
var user model.User
|
||||
require.NoError(t, db.First(&user, "ldap_id = ?", "u-testadmin").Error)
|
||||
assert.True(t, user.IsAdmin)
|
||||
|
||||
var group model.UserGroup
|
||||
require.NoError(t, db.Preload("Users").First(&group, "ldap_id = ?", tt.groupLookup).Error)
|
||||
assert.Equal(t, tt.groupName, group.Name)
|
||||
assert.ElementsMatch(t, []string{"testadmin"}, usernames(group.Users))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func newTestLdapService(t *testing.T, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
return newTestLdapServiceWithAppConfig(t, defaultTestLDAPAppConfig(), client)
|
||||
}
|
||||
|
||||
func newTestLdapServiceWithAppConfig(t *testing.T, appConfigModel *model.AppConfig, client ldapClient) (*LdapService, *gorm.DB) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
appConfig := NewTestAppConfigService(appConfigModel)
|
||||
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
)
|
||||
|
||||
service := NewLdapService(db, &http.Client{}, appConfig, userService, groupService, fileStorage)
|
||||
service.clientFactory = func() (ldapClient, error) {
|
||||
return client, nil
|
||||
}
|
||||
|
||||
return service, db
|
||||
}
|
||||
|
||||
func defaultTestLDAPAppConfig() *model.AppConfig {
|
||||
return &model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
LdapEnabled: model.AppConfigVariable{Value: "true"},
|
||||
LdapBase: model.AppConfigVariable{Value: "dc=example,dc=com"},
|
||||
LdapUserSearchFilter: model.AppConfigVariable{Value: "(objectClass=person)"},
|
||||
LdapUserGroupSearchFilter: model.AppConfigVariable{Value: "(objectClass=groupOfNames)"},
|
||||
LdapAttributeUserUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||
LdapAttributeUserUsername: model.AppConfigVariable{Value: "uid"},
|
||||
LdapAttributeUserEmail: model.AppConfigVariable{Value: "mail"},
|
||||
LdapAttributeUserFirstName: model.AppConfigVariable{Value: "givenName"},
|
||||
LdapAttributeUserLastName: model.AppConfigVariable{Value: "sn"},
|
||||
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "displayName"},
|
||||
LdapAttributeUserProfilePicture: model.AppConfigVariable{Value: "jpegPhoto"},
|
||||
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
|
||||
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{Value: "entryUUID"},
|
||||
LdapAttributeGroupName: model.AppConfigVariable{Value: "cn"},
|
||||
LdapAdminGroupName: model.AppConfigVariable{Value: "admins"},
|
||||
LdapSoftDeleteUsers: model.AppConfigVariable{Value: "true"},
|
||||
}
|
||||
}
|
||||
|
||||
func newFakeLDAPClient(userResult, groupResult *ldap.SearchResult) ldapClient {
|
||||
return &fakeLDAPClient{
|
||||
searchFn: func(searchRequest *ldap.SearchRequest) (*ldap.SearchResult, error) {
|
||||
switch searchRequest.Filter {
|
||||
case "(objectClass=person)":
|
||||
return userResult, nil
|
||||
case "(objectClass=groupOfNames)", "(objectClass=posixGroup)":
|
||||
return groupResult, nil
|
||||
default:
|
||||
return &ldap.SearchResult{}, nil
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ldapSearchResult(entries ...*ldap.Entry) *ldap.SearchResult {
|
||||
return &ldap.SearchResult{Entries: entries}
|
||||
}
|
||||
|
||||
func ldapEntry(dn string, attrs map[string][]string) *ldap.Entry {
|
||||
entry := &ldap.Entry{
|
||||
DN: dn,
|
||||
Attributes: make([]*ldap.EntryAttribute, 0, len(attrs)),
|
||||
}
|
||||
|
||||
for name, values := range attrs {
|
||||
entry.Attributes = append(entry.Attributes, &ldap.EntryAttribute{
|
||||
Name: name,
|
||||
Values: values,
|
||||
})
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
func usernames(users []model.User) []string {
|
||||
result := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
result = append(result, user.Username)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func TestGetDNProperty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -64,10 +465,58 @@ func TestGetDNProperty(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := getDNProperty(tt.property, tt.dn)
|
||||
if result != tt.expectedResult {
|
||||
t.Errorf("getDNProperty(%q, %q) = %q, want %q",
|
||||
tt.property, tt.dn, result, tt.expectedResult)
|
||||
}
|
||||
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeLDAPDN(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "already normalized",
|
||||
input: "cn=alice,dc=example,dc=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "uppercase attribute types",
|
||||
input: "CN=Alice,DC=example,DC=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "spaces after commas",
|
||||
input: "cn=alice, dc=example, dc=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "uppercase types and spaces",
|
||||
input: "CN=Alice, DC=example, DC=com",
|
||||
expected: "cn=alice,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "multi-valued RDN",
|
||||
input: "cn=alice+uid=a123,dc=example,dc=com",
|
||||
expected: "cn=alice+uid=a123,dc=example,dc=com",
|
||||
},
|
||||
{
|
||||
name: "invalid DN falls back to lowercase+trim",
|
||||
input: " NOT A VALID DN ",
|
||||
expected: "not a valid dn",
|
||||
},
|
||||
{
|
||||
name: "empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := normalizeLDAPDN(tt.input)
|
||||
assert.Equalf(t, tt.expected, result, "normalizeLDAPDN(%q)", tt.input)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -98,9 +547,7 @@ func TestConvertLdapIdToString(t *testing.T) {
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := convertLdapIdToString(tt.input)
|
||||
if got != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, got)
|
||||
}
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,11 +123,9 @@ func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
|
||||
)
|
||||
}
|
||||
|
||||
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
|
||||
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID string, authenticationMethod string, ipAddress, userAgent string) (string, string, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
defer tx.Rollback()
|
||||
|
||||
var client model.OidcClient
|
||||
err := tx.
|
||||
@@ -139,27 +137,49 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
if client.RequiresReauthentication {
|
||||
if input.ReauthenticationToken == "" {
|
||||
return "", "", &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
// If the client is not public, the code challenge must be provided
|
||||
if client.IsPublic && input.CodeChallenge == "" {
|
||||
return "", "", &common.OidcMissingCodeChallengeError{}
|
||||
}
|
||||
|
||||
// Get the callback URL of the client. Return an error if the provided callback URL is not allowed
|
||||
// Validate the callback URL before any prompt checks, so that prompt-related
|
||||
// error responses never contain an unvalidated redirect target
|
||||
callbackURL, err := s.getCallbackURL(&client, input.CallbackURL, tx, ctx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Parse prompt parameter (space-delimited list per OIDC spec)
|
||||
promptValues := parsePromptParameter(input.Prompt)
|
||||
hasPromptNone := contains(promptValues, "none")
|
||||
hasPromptLogin := contains(promptValues, "login")
|
||||
hasPromptConsent := contains(promptValues, "consent")
|
||||
hasPromptSelectAccount := contains(promptValues, "select_account")
|
||||
|
||||
// Validate prompt parameter conflicts early.
|
||||
// Per OIDC Core §3.1.2.6, prompt=none must not be combined with any
|
||||
// value that requires user interaction.
|
||||
if hasPromptNone && (hasPromptConsent || hasPromptLogin || hasPromptSelectAccount) {
|
||||
return "", "", &common.OidcInteractionRequiredError{}
|
||||
}
|
||||
|
||||
// Handle prompt=select_account early (not supported)
|
||||
if hasPromptSelectAccount {
|
||||
return "", "", &common.OidcInteractionRequiredError{}
|
||||
}
|
||||
|
||||
// If prompt=login is specified or the client requires reauthentication, check the reauthentication token
|
||||
if hasPromptLogin || client.RequiresReauthentication {
|
||||
if input.ReauthenticationToken == "" {
|
||||
return "", "", &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
err = s.webAuthnService.ConsumeReauthenticationToken(ctx, tx, input.ReauthenticationToken, userID)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the user group is allowed to authorize the client
|
||||
var user model.User
|
||||
err = tx.
|
||||
@@ -175,13 +195,24 @@ func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClie
|
||||
return "", "", &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
// Handle prompt=none - if consent would be required, we can't show UI
|
||||
if hasPromptNone {
|
||||
hasAlreadyAuthorized, err := s.hasAuthorizedClientInternal(ctx, input.ClientID, userID, input.Scope, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if !hasAlreadyAuthorized {
|
||||
return "", "", &common.OidcConsentRequiredError{}
|
||||
}
|
||||
}
|
||||
|
||||
hasAlreadyAuthorizedClient, err := s.createAuthorizedClientInternal(ctx, userID, input.ClientID, input.Scope, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Create the authorization code
|
||||
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
|
||||
code, err := s.createAuthorizationCode(ctx, input.ClientID, userID, input.Scope, authenticationMethod, input.Nonce, input.CodeChallenge, input.CodeChallengeMethod, tx)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
@@ -314,17 +345,17 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
|
||||
}
|
||||
|
||||
// Explicitly use the input clientID for the audience claim to ensure consistency
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce)
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, deviceAuth.Nonce, deviceAuth.AuthenticationMethod)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, tx)
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, *deviceAuth.UserID, deviceAuth.Scope, deviceAuth.AuthenticationMethod, tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID)
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(deviceAuth.User, input.ClientID, deviceAuth.AuthenticationMethod)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -365,7 +396,7 @@ func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, inpu
|
||||
audClaim = input.Resource
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim, "")
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -404,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
||||
}
|
||||
}
|
||||
|
||||
if authorizationCodeMetaData.ClientID != input.ClientID && authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||
if authorizationCodeMetaData.ClientID != input.ClientID || authorizationCodeMetaData.ExpiresAt.ToTime().Before(time.Now()) {
|
||||
return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
|
||||
}
|
||||
|
||||
@@ -413,18 +444,20 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce)
|
||||
authenticationMethod := authorizationCodeMetaData.AuthenticationMethod
|
||||
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, authorizationCodeMetaData.Nonce, authenticationMethod)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// Generate a refresh token
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, tx)
|
||||
refreshToken, err := s.createRefreshToken(ctx, input.ClientID, authorizationCodeMetaData.UserID, authorizationCodeMetaData.Scope, authenticationMethod, tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID)
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(authorizationCodeMetaData.User, input.ClientID, authenticationMethod)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -501,8 +534,46 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
||||
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
|
||||
}
|
||||
|
||||
if storedRefreshToken.User.Disabled {
|
||||
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
|
||||
}
|
||||
|
||||
var authorizedClient model.UserAuthorizedOidcClient
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ? AND client_id = ?", storedRefreshToken.UserID, input.ClientID).
|
||||
First(&authorizedClient).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
err = tx.WithContext(ctx).Delete(&storedRefreshToken).Error
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
|
||||
} else if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
if client.IsGroupRestricted {
|
||||
err = tx.WithContext(ctx).Model(client).Association("AllowedUserGroups").Find(&client.AllowedUserGroups)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if !IsUserGroupAllowedToAuthorize(storedRefreshToken.User, *client) {
|
||||
return CreatedTokens{}, &common.OidcAccessDeniedError{}
|
||||
}
|
||||
|
||||
// Generate a new access token
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID)
|
||||
authenticationMethods := storedRefreshToken.AuthenticationMethod
|
||||
accessToken, err := s.jwtService.GenerateOAuthAccessToken(storedRefreshToken.User, input.ClientID, authenticationMethods)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -515,13 +586,13 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
|
||||
|
||||
// Generate a new ID token
|
||||
// There's no nonce here because we don't have one with the refresh token, but that's not required
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
|
||||
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "", authenticationMethods)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
|
||||
// Generate a new refresh token and invalidate the old one
|
||||
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
|
||||
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, authenticationMethods, tx)
|
||||
if err != nil {
|
||||
return CreatedTokens{}, err
|
||||
}
|
||||
@@ -1142,7 +1213,7 @@ func (s *OidcService) ValidateEndSession(ctx context.Context, input dto.OidcLogo
|
||||
return callbackURL, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
|
||||
func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, nonce string, codeChallenge string, codeChallengeMethod string, tx *gorm.DB) (string, error) {
|
||||
randomString, err := utils.GenerateRandomAlphanumericString(32)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1156,6 +1227,7 @@ func (s *OidcService) createAuthorizationCode(ctx context.Context, clientID stri
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
AuthenticationMethod: authenticationMethod,
|
||||
Nonce: nonce,
|
||||
CodeChallenge: &codeChallenge,
|
||||
CodeChallengeMethodSha256: &codeChallengeMethodSha256,
|
||||
@@ -1299,7 +1371,7 @@ func (s *OidcService) CreateDeviceAuthorization(ctx context.Context, input dto.O
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, ipAddress string, userAgent string) error {
|
||||
func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, userID string, authenticationMethod string, ipAddress string, userAgent string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -1348,6 +1420,7 @@ func (s *OidcService) VerifyDeviceCode(ctx context.Context, userCode string, use
|
||||
}
|
||||
|
||||
deviceAuth.UserID = &userID
|
||||
deviceAuth.AuthenticationMethod = authenticationMethod
|
||||
deviceAuth.IsAuthorized = true
|
||||
|
||||
err = tx.
|
||||
@@ -1464,6 +1537,15 @@ func (s *OidcService) RevokeAuthorizedClient(ctx context.Context, userID string,
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
Where("user_id = ? AND client_id = ?", userID, clientID).
|
||||
Delete(&model.OidcRefreshToken{}).
|
||||
Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -1551,7 +1633,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
|
||||
return dtos, response, err
|
||||
}
|
||||
|
||||
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, tx *gorm.DB) (string, error) {
|
||||
func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, userID string, scope string, authenticationMethod string, tx *gorm.DB) (string, error) {
|
||||
refreshToken, err := utils.GenerateRandomAlphanumericString(40)
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -1562,11 +1644,12 @@ func (s *OidcService) createRefreshToken(ctx context.Context, clientID string, u
|
||||
refreshTokenHash := utils.CreateSha256Hash(refreshToken)
|
||||
|
||||
m := model.OidcRefreshToken{
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
|
||||
Token: refreshTokenHash,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(RefreshTokenDuration)),
|
||||
Token: refreshTokenHash,
|
||||
ClientID: clientID,
|
||||
UserID: userID,
|
||||
Scope: scope,
|
||||
AuthenticationMethod: authenticationMethod,
|
||||
}
|
||||
|
||||
err = tx.
|
||||
@@ -1644,34 +1727,19 @@ func clientAuthCredentialsFromCreateTokensDto(d *dto.OidcCreateTokensDto) Client
|
||||
}
|
||||
|
||||
func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
|
||||
isClientAssertion := input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != ""
|
||||
|
||||
// Determine the client ID based on the authentication method
|
||||
var clientID string
|
||||
switch {
|
||||
case isClientAssertion:
|
||||
// Extract client ID from the JWT assertion's 'sub' claim
|
||||
clientID, err = s.extractClientIDFromAssertion(input.ClientAssertion)
|
||||
if err != nil {
|
||||
slog.Error("Failed to extract client ID from assertion", "error", err)
|
||||
return nil, &common.OidcClientAssertionInvalidError{}
|
||||
}
|
||||
case input.ClientID != "":
|
||||
// Use the provided client ID for other authentication methods
|
||||
clientID = input.ClientID
|
||||
default:
|
||||
if input.ClientID == "" {
|
||||
return nil, &common.OidcMissingClientCredentialsError{}
|
||||
}
|
||||
|
||||
// Load the OIDC client's configuration
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
First(&client, "id = ?", clientID).
|
||||
First(&client, "id = ?", input.ClientID).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
|
||||
return nil, &common.OidcClientAssertionInvalidError{}
|
||||
}
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID))
|
||||
return nil, &common.OidcClientNotFoundError{}
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1686,7 +1754,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
|
||||
return client, nil
|
||||
|
||||
// Next, check if we want to use client assertions from federated identities
|
||||
case isClientAssertion:
|
||||
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "":
|
||||
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
|
||||
@@ -1783,37 +1851,21 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
|
||||
// (Note: we don't use jwt.WithIssuer() because that would be redundant)
|
||||
_, err = jwt.Parse(assertion,
|
||||
jwt.WithValidate(true),
|
||||
|
||||
jwt.WithAcceptableSkew(clockSkew),
|
||||
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
|
||||
jwt.WithAudience(audience),
|
||||
jwt.WithSubject(subject),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client assertion is not valid: %w", err)
|
||||
return fmt.Errorf("client assertion could not be verified: %w", err)
|
||||
}
|
||||
|
||||
// If we're here, the assertion is valid
|
||||
return nil
|
||||
}
|
||||
|
||||
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
|
||||
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
|
||||
// Parse the JWT without verification first to get the claims
|
||||
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
|
||||
}
|
||||
|
||||
// Extract the subject claim which must be the client_id according to RFC 7523
|
||||
sub, ok := insecureToken.Subject()
|
||||
if !ok || sub == "" {
|
||||
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
|
||||
}
|
||||
|
||||
return sub, nil
|
||||
}
|
||||
|
||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
|
||||
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string, authenticationMethod string) (*dto.OidcClientPreviewDto, error) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
@@ -1849,12 +1901,12 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "")
|
||||
idToken, err := s.jwtService.BuildIDToken(userClaims, clientID, "", authenticationMethod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID)
|
||||
accessToken, err := s.jwtService.BuildOAuthAccessToken(user, clientID, authenticationMethod)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -2134,3 +2186,21 @@ func (s *OidcService) GetClientScimServiceProvider(ctx context.Context, clientID
|
||||
|
||||
return provider, nil
|
||||
}
|
||||
|
||||
// parsePromptParameter parses the OIDC prompt parameter which is a space-delimited list of values
|
||||
func parsePromptParameter(prompt string) []string {
|
||||
if prompt == "" {
|
||||
return []string{}
|
||||
}
|
||||
return strings.Fields(prompt)
|
||||
}
|
||||
|
||||
// contains checks if a string slice contains a specific value
|
||||
func contains(slice []string, value string) bool {
|
||||
for _, item := range slice {
|
||||
if item == value {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
@@ -229,6 +230,12 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
Subject: federatedClient.ID,
|
||||
JWKS: federatedClientIssuer + "/jwks.json",
|
||||
},
|
||||
{
|
||||
Issuer: "federated-issuer-2",
|
||||
Audience: federatedClientAudience,
|
||||
Subject: "my-federated-client",
|
||||
JWKS: federatedClientIssuer + "/jwks.json",
|
||||
},
|
||||
{Issuer: federatedClientIssuerDefaults},
|
||||
},
|
||||
},
|
||||
@@ -461,6 +468,43 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
|
||||
// Generate a token
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: federatedClient.ID,
|
||||
ClientAssertion: string(signedToken),
|
||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||
}
|
||||
createdToken, err := s.createTokenFromClientCredentials(t.Context(), input)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, token)
|
||||
|
||||
// Verify the token
|
||||
claims, err := s.jwtService.VerifyOAuthAccessToken(createdToken.AccessToken)
|
||||
require.NoError(t, err, "Failed to verify generated token")
|
||||
|
||||
// Check the claims
|
||||
subject, ok := claims.Subject()
|
||||
_ = assert.True(t, ok, "User ID not found in token") &&
|
||||
assert.Equal(t, "client-"+federatedClient.ID, subject, "Token subject should match federated client ID with prefix")
|
||||
audience, ok := claims.Audience()
|
||||
_ = assert.True(t, ok, "Audience not found in token") &&
|
||||
assert.Equal(t, []string{federatedClient.ID}, audience, "Audience should contain the federated client ID")
|
||||
})
|
||||
|
||||
t.Run("Succeeds with valid assertion and custom subject", func(t *testing.T) {
|
||||
// Create JWT for federated identity
|
||||
token, err := jwt.NewBuilder().
|
||||
Issuer("federated-issuer-2").
|
||||
Audience([]string{federatedClientAudience}).
|
||||
Subject("my-federated-client").
|
||||
IssuedAt(time.Now()).
|
||||
Expiration(time.Now().Add(10 * time.Minute)).
|
||||
Build()
|
||||
require.NoError(t, err)
|
||||
signedToken, err := jwt.Sign(token, jwt.WithKey(jwa.ES256(), privateJWK))
|
||||
require.NoError(t, err)
|
||||
|
||||
// Generate a token
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: federatedClient.ID,
|
||||
ClientAssertion: string(signedToken),
|
||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||
}
|
||||
@@ -483,6 +527,7 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
|
||||
t.Run("Fails with invalid assertion", func(t *testing.T) {
|
||||
input := dto.OidcCreateTokensDto{
|
||||
ClientID: confidentialClient.ID,
|
||||
ClientAssertion: "invalid.jwt.token",
|
||||
ClientAssertionType: ClientAssertionTypeJWTBearer,
|
||||
}
|
||||
@@ -518,6 +563,180 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestOidcServiceRefreshTokenAuthorizationState(t *testing.T) {
|
||||
newFixture := func(t *testing.T, isGroupRestricted bool) (*OidcService, *gorm.DB, model.User, model.OidcClient, string, string, *model.UserGroup) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
})
|
||||
jwtService, err := NewJwtService(t.Context(), db, mockConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
service := &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
appConfigService: mockConfig,
|
||||
}
|
||||
|
||||
email := "refresh-token-user@example.com"
|
||||
user := model.User{
|
||||
Username: "refresh-token-user",
|
||||
Email: &email,
|
||||
EmailVerified: true,
|
||||
FirstName: "Refresh",
|
||||
LastName: "User",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
client, err := service.CreateClient(t.Context(), dto.OidcClientCreateDto{
|
||||
OidcClientUpdateDto: dto.OidcClientUpdateDto{
|
||||
Name: "Refresh Token Client",
|
||||
CallbackURLs: []string{"https://example.com/callback"},
|
||||
IsGroupRestricted: isGroupRestricted,
|
||||
},
|
||||
}, user.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
clientSecret, err := service.CreateClientSecret(t.Context(), client.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var userGroup *model.UserGroup
|
||||
if isGroupRestricted {
|
||||
group := model.UserGroup{
|
||||
FriendlyName: "Allowed Group",
|
||||
Name: "allowed-group",
|
||||
}
|
||||
require.NoError(t, db.Create(&group).Error)
|
||||
require.NoError(t, db.Model(&user).Association("UserGroups").Append(&group))
|
||||
require.NoError(t, db.Model(&client).Association("AllowedUserGroups").Append(&group))
|
||||
userGroup = &group
|
||||
}
|
||||
|
||||
scope := "openid profile email groups"
|
||||
require.NoError(t, db.Create(&model.UserAuthorizedOidcClient{
|
||||
UserID: user.ID,
|
||||
ClientID: client.ID,
|
||||
Scope: scope,
|
||||
}).Error)
|
||||
|
||||
refreshToken, err := service.createRefreshToken(t.Context(), client.ID, user.ID, scope, AuthenticationMethodPhishingResistant, db)
|
||||
require.NoError(t, err)
|
||||
|
||||
return service, db, user, client, clientSecret, refreshToken, userGroup
|
||||
}
|
||||
|
||||
refreshInput := func(client model.OidcClient, clientSecret string, refreshToken string) dto.OidcCreateTokensDto {
|
||||
return dto.OidcCreateTokensDto{
|
||||
GrantType: GrantTypeRefreshToken,
|
||||
RefreshToken: refreshToken,
|
||||
ClientID: client.ID,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("rejects refresh token after authorization revocation", func(t *testing.T) {
|
||||
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
|
||||
|
||||
err := service.RevokeAuthorizedClient(t.Context(), user.ID, client.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
var refreshTokenCount int64
|
||||
require.NoError(t, db.Model(&model.OidcRefreshToken{}).
|
||||
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
|
||||
Count(&refreshTokenCount).Error)
|
||||
assert.Zero(t, refreshTokenCount)
|
||||
|
||||
_, err = service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
|
||||
})
|
||||
|
||||
t.Run("rejects and deletes stale refresh token without authorization record", func(t *testing.T) {
|
||||
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
|
||||
|
||||
require.NoError(t, db.
|
||||
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
|
||||
Delete(&model.UserAuthorizedOidcClient{}).Error)
|
||||
|
||||
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
|
||||
|
||||
var refreshTokenCount int64
|
||||
require.NoError(t, db.Model(&model.OidcRefreshToken{}).
|
||||
Where("user_id = ? AND client_id = ?", user.ID, client.ID).
|
||||
Count(&refreshTokenCount).Error)
|
||||
assert.Zero(t, refreshTokenCount)
|
||||
})
|
||||
|
||||
t.Run("rejects refresh token for disabled user", func(t *testing.T) {
|
||||
service, db, user, client, clientSecret, refreshToken, _ := newFixture(t, false)
|
||||
|
||||
require.NoError(t, db.Model(&model.User{}).
|
||||
Where("id = ?", user.ID).
|
||||
Update("disabled", true).Error)
|
||||
|
||||
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcInvalidRefreshTokenError{})
|
||||
})
|
||||
|
||||
t.Run("rejects refresh token after user leaves allowed group", func(t *testing.T) {
|
||||
service, db, user, client, clientSecret, refreshToken, userGroup := newFixture(t, true)
|
||||
require.NotNil(t, userGroup)
|
||||
|
||||
require.NoError(t, db.Model(&user).Association("UserGroups").Delete(userGroup))
|
||||
|
||||
_, err := service.createTokenFromRefreshToken(t.Context(), refreshInput(client, clientSecret, refreshToken))
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, &common.OidcAccessDeniedError{})
|
||||
})
|
||||
}
|
||||
|
||||
func TestOidcServiceAuthenticationMethodsPersistence(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
})
|
||||
jwtService, db, _ := setupJwtService(t, mockConfig)
|
||||
service := &OidcService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
}
|
||||
authenticationMethod := AuthenticationMethodPhishingResistant
|
||||
|
||||
t.Run("stores authentication method on authorization codes", func(t *testing.T) {
|
||||
code, err := service.createAuthorizationCode(
|
||||
t.Context(),
|
||||
"amr-client",
|
||||
"amr-user",
|
||||
"openid profile",
|
||||
authenticationMethod,
|
||||
"",
|
||||
"",
|
||||
"",
|
||||
db,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
|
||||
var authorizationCode model.OidcAuthorizationCode
|
||||
require.NoError(t, db.First(&authorizationCode, "code = ?", code).Error)
|
||||
assert.Equal(t, authenticationMethod, authorizationCode.AuthenticationMethod)
|
||||
})
|
||||
|
||||
t.Run("stores authentication methods on refresh tokens", func(t *testing.T) {
|
||||
_, err := service.createRefreshToken(t.Context(), "amr-client", "amr-user", "openid profile", authenticationMethod, db)
|
||||
require.NoError(t, err)
|
||||
|
||||
var refreshToken model.OidcRefreshToken
|
||||
require.NoError(t, db.First(&refreshToken, "client_id = ? AND user_id = ?", "amr-client", "amr-user").Error)
|
||||
assert.Equal(t, authenticationMethod, refreshToken.AuthenticationMethod)
|
||||
})
|
||||
}
|
||||
|
||||
func TestValidateCodeVerifier_Plain(t *testing.T) {
|
||||
require.False(t, validateCodeVerifier("", "", false))
|
||||
require.False(t, validateCodeVerifier("", "", true))
|
||||
@@ -974,3 +1193,73 @@ func TestOidcService_downloadAndSaveLogoFromURL(t *testing.T) {
|
||||
require.ErrorContains(t, err, "failed to look up client")
|
||||
})
|
||||
}
|
||||
|
||||
// Tests for prompt parameter parsing and handling
|
||||
func TestParsePromptParameter(t *testing.T) {
|
||||
t.Run("empty prompt returns empty slice", func(t *testing.T) {
|
||||
result := parsePromptParameter("")
|
||||
assert.Equal(t, []string{}, result)
|
||||
})
|
||||
|
||||
t.Run("single prompt value", func(t *testing.T) {
|
||||
result := parsePromptParameter("none")
|
||||
assert.Equal(t, []string{"none"}, result)
|
||||
})
|
||||
|
||||
t.Run("multiple prompt values space-delimited", func(t *testing.T) {
|
||||
result := parsePromptParameter("login consent")
|
||||
assert.Equal(t, []string{"login", "consent"}, result)
|
||||
})
|
||||
|
||||
t.Run("multiple prompt values with extra spaces", func(t *testing.T) {
|
||||
result := parsePromptParameter(" none login ")
|
||||
assert.Equal(t, []string{"none", "login"}, result)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPromptParameterConflicts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prompt string
|
||||
expectConflict bool
|
||||
}{
|
||||
{"none alone is valid", "none", false},
|
||||
{"login alone is valid", "login", false},
|
||||
{"consent alone is valid", "consent", false},
|
||||
{"login consent is valid", "login consent", false},
|
||||
{"none consent conflicts", "none consent", true},
|
||||
{"none login conflicts", "none login", true},
|
||||
{"none select_account conflicts", "none select_account", true},
|
||||
{"none consent login conflicts", "none consent login", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
values := parsePromptParameter(tt.prompt)
|
||||
hasNone := contains(values, "none")
|
||||
hasConsent := contains(values, "consent")
|
||||
hasLogin := contains(values, "login")
|
||||
hasSelectAccount := contains(values, "select_account")
|
||||
|
||||
conflict := hasNone && (hasConsent || hasLogin || hasSelectAccount)
|
||||
assert.Equal(t, tt.expectConflict, conflict)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestContains(t *testing.T) {
|
||||
t.Run("finds value in slice", func(t *testing.T) {
|
||||
slice := []string{"none", "login", "consent"}
|
||||
assert.True(t, contains(slice, "login"))
|
||||
})
|
||||
|
||||
t.Run("returns false for missing value", func(t *testing.T) {
|
||||
slice := []string{"none", "login"}
|
||||
assert.False(t, contains(slice, "consent"))
|
||||
})
|
||||
|
||||
t.Run("returns false for empty slice", func(t *testing.T) {
|
||||
slice := []string{}
|
||||
assert.False(t, contains(slice, "none"))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
user, err := s.userService.GetUser(ctx, userID)
|
||||
user, err := s.userService.getUserInternal(ctx, userID, tx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -131,8 +131,32 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
|
||||
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
|
||||
return token, err
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
// Load the user to ensure it exists
|
||||
_, err = s.userService.getUserInternal(ctx, userID, tx)
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", &common.UserNotFoundError{}
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Create the one-time access token
|
||||
token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, tx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Commit
|
||||
err = tx.Commit().Error
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {
|
||||
@@ -173,7 +197,7 @@ func (s *OneTimeAccessService) ExchangeOneTimeAccessToken(ctx context.Context, t
|
||||
return model.User{}, "", &common.DeviceCodeInvalid{}
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User)
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(oneTimeAccessToken.User, AuthenticationMethodOneTimePassword)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
25
backend/internal/service/scheduler.go
Normal file
25
backend/internal/service/scheduler.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
backoff "github.com/cenkalti/backoff/v5"
|
||||
"github.com/go-co-op/gocron/v2"
|
||||
)
|
||||
|
||||
// RegisterJobOpts holds optional configuration for registering a scheduled job.
|
||||
type RegisterJobOpts struct {
|
||||
// RunImmediately runs the job immediately after registration.
|
||||
RunImmediately bool
|
||||
// ExtraOptions are additional gocron job options.
|
||||
ExtraOptions []gocron.JobOption
|
||||
// BackOff is an optional backoff strategy. If non-nil, the job will be wrapped
|
||||
// with automatic retry logic using the provided backoff on transient failures.
|
||||
BackOff backoff.BackOff
|
||||
}
|
||||
|
||||
// Scheduler is an interface for registering and managing background jobs.
|
||||
type Scheduler interface {
|
||||
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, opts RegisterJobOpts) error
|
||||
RemoveJob(name string) error
|
||||
}
|
||||
@@ -34,11 +34,6 @@ const scimErrorBodyLimit = 4096
|
||||
|
||||
type scimSyncAction int
|
||||
|
||||
type Scheduler interface {
|
||||
RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) error
|
||||
RemoveJob(name string) error
|
||||
}
|
||||
|
||||
const (
|
||||
scimActionNone scimSyncAction = iota
|
||||
scimActionCreated
|
||||
@@ -149,7 +144,7 @@ func (s *ScimService) ScheduleSync() {
|
||||
|
||||
err := s.scheduler.RegisterJob(
|
||||
context.Background(), jobName,
|
||||
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
|
||||
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{})
|
||||
|
||||
if err != nil {
|
||||
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
|
||||
@@ -168,7 +163,8 @@ func (s *ScimService) SyncAll(ctx context.Context) error {
|
||||
errs = append(errs, ctx.Err())
|
||||
break
|
||||
}
|
||||
if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
|
||||
err = s.SyncServiceProvider(ctx, provider.ID)
|
||||
if err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
|
||||
}
|
||||
}
|
||||
@@ -210,26 +206,20 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
|
||||
}
|
||||
|
||||
var errs []error
|
||||
var userStats scimSyncStats
|
||||
var groupStats scimSyncStats
|
||||
|
||||
// Sync users first, so that groups can reference them
|
||||
if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
|
||||
errs = append(errs, err)
|
||||
userStats = stats
|
||||
} else {
|
||||
userStats = stats
|
||||
}
|
||||
|
||||
stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
||||
userStats, err := s.syncUsers(ctx, provider, users, &userResources)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
|
||||
groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
groupStats = stats
|
||||
} else {
|
||||
groupStats = stats
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
err = errors.Join(errs...)
|
||||
slog.WarnContext(ctx, "SCIM sync completed with errors",
|
||||
slog.String("provider_id", provider.ID),
|
||||
slog.Int("error_count", len(errs)),
|
||||
@@ -240,12 +230,14 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
|
||||
slog.Int("groups_updated", groupStats.Updated),
|
||||
slog.Int("groups_deleted", groupStats.Deleted),
|
||||
slog.Duration("duration", time.Since(start)),
|
||||
slog.Any("error", err),
|
||||
)
|
||||
return errors.Join(errs...)
|
||||
return err
|
||||
}
|
||||
|
||||
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
|
||||
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
||||
err = s.db.WithContext(ctx).Save(&provider).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -273,7 +265,7 @@ func (s *ScimService) syncUsers(
|
||||
|
||||
// Update or create users
|
||||
for _, u := range users {
|
||||
existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
|
||||
existing := getResourceByExternalID(u.ID, resourceList.Resources)
|
||||
|
||||
action, created, err := s.syncUser(ctx, provider, u, existing)
|
||||
if created != nil && existing == nil {
|
||||
@@ -434,7 +426,7 @@ func (s *ScimService) syncGroup(
|
||||
// Prepare group members
|
||||
members := make([]dto.ScimGroupMember, len(group.Users))
|
||||
for i, user := range group.Users {
|
||||
userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
|
||||
userResource := getResourceByExternalID(user.ID, userResources)
|
||||
if userResource == nil {
|
||||
// Groups depend on user IDs already being provisioned
|
||||
return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
|
||||
|
||||
@@ -96,7 +96,10 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -126,7 +129,10 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -175,7 +181,10 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -238,7 +247,10 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -315,6 +327,9 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
|
||||
return model.UserGroup{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return group, nil
|
||||
}
|
||||
|
||||
@@ -225,7 +225,10 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
|
||||
return fmt.Errorf("failed to delete user: %w", err)
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -310,7 +313,10 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
|
||||
}
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -456,7 +462,10 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
return user, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -515,7 +524,10 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return user, nil
|
||||
}
|
||||
|
||||
@@ -576,7 +588,10 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
|
||||
return err
|
||||
}
|
||||
|
||||
s.scimService.ScheduleSync()
|
||||
if s.scimService != nil {
|
||||
s.scimService.ScheduleSync()
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -87,7 +87,7 @@ func (s *UserSignUpService) SignUp(ctx context.Context, signupData dto.SignUpDto
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(user)
|
||||
accessToken, err := s.jwtService.GenerateAccessToken(user, "")
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -124,14 +124,12 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
var userCount int64
|
||||
if err := tx.WithContext(ctx).Model(&model.User{}).
|
||||
Where("id != ?", staticApiKeyUserID).
|
||||
Count(&userCount).Error; err != nil {
|
||||
setupCompleted, err := s.isInitialAdminSetupCompleted(ctx, tx)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
if userCount != 0 {
|
||||
return model.User{}, "", &common.SetupAlreadyCompletedError{}
|
||||
if setupCompleted {
|
||||
return model.User{}, "", &common.SetupNotAvailableError{}
|
||||
}
|
||||
|
||||
userToCreate := dto.UserCreateDto{
|
||||
@@ -148,7 +146,7 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
|
||||
return model.User{}, "", err
|
||||
}
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(user)
|
||||
token, err := s.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -161,6 +159,21 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
|
||||
return user, token, nil
|
||||
}
|
||||
|
||||
func (s *UserSignUpService) IsInitialAdminSetupCompleted(ctx context.Context) (bool, error) {
|
||||
return s.isInitialAdminSetupCompleted(ctx, s.db)
|
||||
}
|
||||
|
||||
func (s *UserSignUpService) isInitialAdminSetupCompleted(ctx context.Context, db *gorm.DB) (bool, error) {
|
||||
var userCount int64
|
||||
if err := db.WithContext(ctx).Model(&model.User{}).
|
||||
Where("id != ?", staticApiKeyUserID).
|
||||
Count(&userCount).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
return userCount != 0, nil
|
||||
}
|
||||
|
||||
func (s *UserSignUpService) ListSignupTokens(ctx context.Context, listRequestOptions utils.ListRequestOptions) ([]model.SignupToken, utils.PaginationResponse, error) {
|
||||
var tokens []model.SignupToken
|
||||
query := s.db.WithContext(ctx).Preload("UserGroups").Model(&model.SignupToken{})
|
||||
|
||||
@@ -266,7 +266,7 @@ func (s *WebAuthnService) VerifyLogin(ctx context.Context, sessionID string, cre
|
||||
return model.User{}, "", &common.UserDisabledError{}
|
||||
}
|
||||
|
||||
token, err := s.jwtService.GenerateAccessToken(*user)
|
||||
token, err := s.jwtService.GenerateAccessToken(*user, AuthenticationMethodPhishingResistant)
|
||||
if err != nil {
|
||||
return model.User{}, "", err
|
||||
}
|
||||
@@ -293,26 +293,40 @@ func (s *WebAuthnService) ListCredentials(ctx context.Context, userID string) ([
|
||||
return credentials, nil
|
||||
}
|
||||
|
||||
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string) error {
|
||||
func (s *WebAuthnService) DeleteCredential(ctx context.Context, userID string, credentialID string, ipAddress string, userAgent string, actorUserID string) error {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
tx.Rollback()
|
||||
}()
|
||||
|
||||
credential := &model.WebauthnCredential{}
|
||||
err := tx.
|
||||
result := tx.
|
||||
WithContext(ctx).
|
||||
Clauses(clause.Returning{}).
|
||||
Delete(credential, "id = ? AND user_id = ?", credentialID, userID).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete record: %w", err)
|
||||
Delete(credential, "id = ? AND user_id = ?", credentialID, userID)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("failed to delete record: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
auditLogData := model.AuditLogData{"credentialID": hex.EncodeToString(credential.CredentialID), "passkeyName": credential.Name}
|
||||
if actorUserID != "" && actorUserID != userID {
|
||||
var actor model.User
|
||||
err := tx.
|
||||
WithContext(ctx).
|
||||
First(&actor, "id = ?", actorUserID).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load actor user: %w", err)
|
||||
}
|
||||
auditLogData["actorUserID"] = actorUserID
|
||||
auditLogData["actorUsername"] = actor.Username
|
||||
}
|
||||
s.auditLogService.Create(ctx, model.AuditLogEventPasskeyRemoved, ipAddress, userAgent, userID, auditLogData, tx)
|
||||
|
||||
err = tx.Commit().Error
|
||||
err := tx.Commit().Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to commit transaction: %w", err)
|
||||
}
|
||||
@@ -375,6 +389,14 @@ func (s *WebAuthnService) CreateReauthenticationTokenWithAccessToken(ctx context
|
||||
return "", errors.New("access token does not contain user ID")
|
||||
}
|
||||
|
||||
authenticationMethod, err := GetAuthenticationMethod(token)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if authenticationMethod != AuthenticationMethodPhishingResistant {
|
||||
return "", &common.ReauthenticationRequiredError{}
|
||||
}
|
||||
|
||||
// Check if token is issued less than a minute ago
|
||||
tokenExpiration, ok := token.IssuedAt()
|
||||
if !ok || time.Since(tokenExpiration) > time.Minute {
|
||||
|
||||
68
backend/internal/service/webauthn_service_test.go
Normal file
68
backend/internal/service/webauthn_service_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
)
|
||||
|
||||
func TestCreateReauthenticationTokenWithAccessToken(t *testing.T) {
|
||||
mockConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
SessionDuration: model.AppConfigVariable{Value: "60"},
|
||||
})
|
||||
|
||||
setupService := func(t *testing.T) (*WebAuthnService, model.User) {
|
||||
t.Helper()
|
||||
|
||||
jwtService, db, _ := setupJwtService(t, mockConfig)
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "reauth-user"},
|
||||
Username: "reauth-user",
|
||||
}
|
||||
require.NoError(t, db.Create(&user).Error)
|
||||
|
||||
return &WebAuthnService{
|
||||
db: db,
|
||||
jwtService: jwtService,
|
||||
}, user
|
||||
}
|
||||
|
||||
t.Run("accepts a fresh access token from WebAuthn login", func(t *testing.T) {
|
||||
service, user := setupService(t)
|
||||
accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodPhishingResistant)
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, reauthenticationToken)
|
||||
})
|
||||
|
||||
t.Run("rejects a fresh access token from one-time access login", func(t *testing.T) {
|
||||
service, user := setupService(t)
|
||||
accessToken, err := service.jwtService.GenerateAccessToken(user, AuthenticationMethodOneTimePassword)
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
||||
|
||||
assert.Empty(t, reauthenticationToken)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
|
||||
})
|
||||
|
||||
t.Run("rejects a fresh access token without an authentication method", func(t *testing.T) {
|
||||
service, user := setupService(t)
|
||||
accessToken, err := service.jwtService.GenerateAccessToken(user, "")
|
||||
require.NoError(t, err)
|
||||
|
||||
reauthenticationToken, err := service.CreateReauthenticationTokenWithAccessToken(t.Context(), accessToken)
|
||||
|
||||
assert.Empty(t, reauthenticationToken)
|
||||
require.Error(t, err)
|
||||
assert.ErrorAs(t, err, new(*common.ReauthenticationRequiredError))
|
||||
})
|
||||
}
|
||||
@@ -138,7 +138,7 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error)
|
||||
continue
|
||||
}
|
||||
objects = append(objects, ObjectInfo{
|
||||
Path: aws.ToString(obj.Key),
|
||||
Path: s.pathFromKey(aws.ToString(obj.Key)),
|
||||
Size: aws.ToInt64(obj.Size),
|
||||
ModTime: aws.ToTime(obj.LastModified),
|
||||
})
|
||||
@@ -147,6 +147,13 @@ func (s *s3Storage) List(ctx context.Context, path string) ([]ObjectInfo, error)
|
||||
return objects, nil
|
||||
}
|
||||
|
||||
func (s *s3Storage) pathFromKey(key string) string {
|
||||
if s.prefix == "" {
|
||||
return key
|
||||
}
|
||||
return strings.TrimPrefix(key, s.prefix+"/")
|
||||
}
|
||||
|
||||
func (s *s3Storage) Walk(ctx context.Context, root string, fn func(ObjectInfo) error) error {
|
||||
objects, err := s.List(ctx, root)
|
||||
if err != nil {
|
||||
|
||||
@@ -35,6 +35,50 @@ func TestS3Helpers(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pathFromKey strips prefix to honor relative-path contract", func(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
prefix string
|
||||
key string
|
||||
expected string
|
||||
}{
|
||||
{name: "no prefix returns key unchanged", prefix: "", key: "images/logo.png", expected: "images/logo.png"},
|
||||
{name: "no prefix empty key", prefix: "", key: "", expected: ""},
|
||||
{name: "prefix matches and is stripped", prefix: "data/uploads", key: "data/uploads/application-images/logo.svg", expected: "application-images/logo.svg"},
|
||||
{name: "single-segment prefix stripped", prefix: "root", key: "root/foo/bar.txt", expected: "foo/bar.txt"},
|
||||
{name: "prefix equal to key without trailing slash is unchanged", prefix: "root", key: "root", expected: "root"},
|
||||
{name: "key without expected prefix returned unchanged", prefix: "data/uploads", key: "other/path.txt", expected: "other/path.txt"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := &s3Storage{
|
||||
bucket: "bucket",
|
||||
prefix: tc.prefix,
|
||||
}
|
||||
assert.Equal(t, tc.expected, s.pathFromKey(tc.key))
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pathFromKey is the inverse of buildObjectKey for clean paths", func(t *testing.T) {
|
||||
paths := []string{
|
||||
"images/logo.png",
|
||||
"application-images/logo.svg",
|
||||
"oidc-client-images/abc.png",
|
||||
"deeply/nested/file.bin",
|
||||
}
|
||||
prefixes := []string{"", "root", "data/uploads"}
|
||||
|
||||
for _, prefix := range prefixes {
|
||||
for _, p := range paths {
|
||||
s := &s3Storage{bucket: "bucket", prefix: prefix}
|
||||
assert.Equal(t, p, s.pathFromKey(s.buildObjectKey(p)),
|
||||
"round-trip failed for prefix=%q path=%q", prefix, p)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("isS3NotFound detects expected errors", func(t *testing.T) {
|
||||
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NoSuchKey"}))
|
||||
assert.True(t, isS3NotFound(&smithy.GenericAPIError{Code: "NotFound"}))
|
||||
|
||||
@@ -1,14 +1,31 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/url"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/dunglas/go-urlpattern"
|
||||
)
|
||||
|
||||
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL
|
||||
// ValidateCallbackURLPattern checks if the given callback URL pattern
|
||||
// is valid according to the rules defined in this package.
|
||||
func ValidateCallbackURLPattern(pattern string) error {
|
||||
if pattern == "*" {
|
||||
return nil
|
||||
}
|
||||
|
||||
pattern, _, _ = strings.Cut(pattern, "#")
|
||||
pattern = normalizeToURLPatternStandard(pattern)
|
||||
|
||||
_, err := urlpattern.New(pattern, "", nil)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetCallbackURLFromList returns the first callback URL that matches the input callback URL.
|
||||
func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
|
||||
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
|
||||
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
|
||||
@@ -17,17 +34,7 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
|
||||
// time of the request for loopback IP redirect URIs, to accommodate
|
||||
// clients that obtain an available ephemeral port from the operating
|
||||
// system at the time of the request.
|
||||
loopbackCallbackURLWithoutPort := ""
|
||||
u, _ := url.Parse(inputCallbackURL)
|
||||
|
||||
if u != nil && u.Scheme == "http" {
|
||||
host := u.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if host == "localhost" || (ip != nil && ip.IsLoopback()) {
|
||||
u.Host = host
|
||||
loopbackCallbackURLWithoutPort = u.String()
|
||||
}
|
||||
}
|
||||
loopbackCallbackURLWithoutPort := loopbackURLWithWildcardPort(inputCallbackURL)
|
||||
|
||||
for _, pattern := range urls {
|
||||
// Try the original callback first
|
||||
@@ -54,6 +61,28 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
|
||||
return "", nil
|
||||
}
|
||||
|
||||
func loopbackURLWithWildcardPort(input string) string {
|
||||
u, _ := url.Parse(input)
|
||||
|
||||
if u == nil || u.Scheme != "http" {
|
||||
return ""
|
||||
}
|
||||
|
||||
host := u.Hostname()
|
||||
ip := net.ParseIP(host)
|
||||
if host != "localhost" && (ip == nil || !ip.IsLoopback()) {
|
||||
return ""
|
||||
}
|
||||
|
||||
// For IPv6 loopback hosts, brackets are required when serializing without a port.
|
||||
if strings.Contains(host, ":") {
|
||||
u.Host = "[" + host + "]"
|
||||
} else {
|
||||
u.Host = host
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// matchCallbackURL checks if the input callback URL matches the given pattern.
|
||||
// It supports wildcard matching for paths and query parameters.
|
||||
//
|
||||
@@ -64,143 +93,176 @@ func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, er
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// Strip fragment part
|
||||
// Strip fragment part.
|
||||
// The endpoint URI MUST NOT include a fragment component.
|
||||
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
|
||||
pattern, _, _ = strings.Cut(pattern, "#")
|
||||
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
|
||||
|
||||
// Store and strip query part
|
||||
var patternQuery url.Values
|
||||
if i := strings.Index(pattern, "?"); i >= 0 {
|
||||
patternQuery, err = url.ParseQuery(pattern[i+1:])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
pattern = pattern[:i]
|
||||
}
|
||||
var inputQuery url.Values
|
||||
if i := strings.Index(inputCallbackURL, "?"); i >= 0 {
|
||||
inputQuery, err = url.ParseQuery(inputCallbackURL[i+1:])
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
inputCallbackURL = inputCallbackURL[:i]
|
||||
}
|
||||
|
||||
// Split both pattern and input parts
|
||||
patternParts, patternPath := splitParts(pattern)
|
||||
inputParts, inputPath := splitParts(inputCallbackURL)
|
||||
|
||||
// Verify everything except the path and query parameters
|
||||
if len(patternParts) != len(inputParts) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
for i, patternPart := range patternParts {
|
||||
matched, err := path.Match(patternPart, inputParts[i])
|
||||
if err != nil || !matched {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
|
||||
// Verify path with wildcard support
|
||||
matched, err := matchPath(patternPath, inputPath)
|
||||
if err != nil || !matched {
|
||||
pattern, patternQuery, err := extractQueryParams(pattern)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
// Verify query parameters
|
||||
if len(patternQuery) != len(inputQuery) {
|
||||
inputCallbackURL, inputQuery, err := extractQueryParams(inputCallbackURL)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
pattern = normalizeToURLPatternStandard(pattern)
|
||||
|
||||
// Validate query params
|
||||
v := validateQueryParams(patternQuery, inputQuery)
|
||||
if !v {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Validate the rest of the URL using urlpattern
|
||||
p, err := urlpattern.New(pattern, "", nil)
|
||||
if err != nil {
|
||||
//nolint:nilerr
|
||||
slog.Warn("invalid callback URL pattern, skipping", "pattern", pattern, "error", err)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
return p.Test(inputCallbackURL, ""), nil
|
||||
}
|
||||
|
||||
// normalizeToURLPatternStandard converts patterns with single asterisk wildcards and globstar wildcards
|
||||
// into a format that can be parsed by the urlpattern package, which uses :param for single segment wildcards
|
||||
// and ** for multi-segment wildcards.
|
||||
// Additionally, it escapes ":" with a backslash inside IPv6 addresses
|
||||
func normalizeToURLPatternStandard(pattern string) string {
|
||||
patternBase, patternPath := extractPath(pattern)
|
||||
|
||||
var result strings.Builder
|
||||
result.Grow(len(pattern) + 5) // Add 5 for some extra capacity, hoping to avoid many re-allocations
|
||||
|
||||
// First, process the base
|
||||
|
||||
// 0 = scheme
|
||||
// 1 = hostname (optionally with username/password) - before IPv6 start (no `[` found)
|
||||
// 2 = is matching IPv6 (until `]`)
|
||||
// 3 = after hostname
|
||||
var step int
|
||||
for i := 0; i < len(patternBase); i++ {
|
||||
switch step {
|
||||
case 0:
|
||||
if i > 3 && patternBase[i] == '/' && patternBase[i-1] == '/' && patternBase[i-2] == ':' {
|
||||
// We just passed the scheme
|
||||
step = 1
|
||||
}
|
||||
case 1:
|
||||
switch patternBase[i] {
|
||||
case '/', ']':
|
||||
// No IPv6, skip to end of this logic
|
||||
step = 3
|
||||
case '[':
|
||||
// Start of IPv6 match
|
||||
step = 2
|
||||
}
|
||||
case 2:
|
||||
if patternBase[i] == '/' || patternBase[i] == ']' || patternBase[i] == '[' {
|
||||
// End of IPv6 match
|
||||
step = 3
|
||||
}
|
||||
|
||||
switch patternBase[i] {
|
||||
case ':':
|
||||
// We are matching an IPv6 block and there's a colon, so escape that
|
||||
result.WriteByte('\\')
|
||||
case '/', ']', '[':
|
||||
// End of IPv6 match
|
||||
step = 3
|
||||
}
|
||||
}
|
||||
|
||||
// Write the byte
|
||||
result.WriteByte(patternBase[i])
|
||||
}
|
||||
|
||||
// Next, process the path
|
||||
for i := 0; i < len(patternPath); i++ {
|
||||
if patternPath[i] == '*' {
|
||||
// Replace globstar with a single asterisk
|
||||
if i+1 < len(patternPath) && patternPath[i+1] == '*' {
|
||||
result.WriteString("*")
|
||||
i++ // skip next *
|
||||
} else {
|
||||
// Replace single asterisk with :p{index}
|
||||
result.WriteString(":p")
|
||||
result.WriteString(strconv.Itoa(i))
|
||||
}
|
||||
} else {
|
||||
// Add the byte
|
||||
result.WriteByte(patternPath[i])
|
||||
}
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
func extractPath(url string) (base string, path string) {
|
||||
pathStart := -1
|
||||
|
||||
// Look for scheme:// first
|
||||
i := strings.Index(url, "://")
|
||||
if i >= 0 {
|
||||
// Look for the next slash after scheme://
|
||||
rest := url[i+3:]
|
||||
if j := strings.IndexByte(rest, '/'); j >= 0 {
|
||||
pathStart = i + 3 + j
|
||||
}
|
||||
} else {
|
||||
// Otherwise, first slash is path start
|
||||
pathStart = strings.IndexByte(url, '/')
|
||||
}
|
||||
|
||||
if pathStart >= 0 {
|
||||
path = url[pathStart:]
|
||||
base = url[:pathStart]
|
||||
} else {
|
||||
path = ""
|
||||
base = url
|
||||
}
|
||||
|
||||
return base, path
|
||||
}
|
||||
|
||||
func extractQueryParams(rawUrl string) (base string, query url.Values, err error) {
|
||||
if i := strings.IndexByte(rawUrl, '?'); i >= 0 {
|
||||
query, err = url.ParseQuery(rawUrl[i+1:])
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
rawUrl = rawUrl[:i]
|
||||
}
|
||||
|
||||
return rawUrl, query, nil
|
||||
}
|
||||
|
||||
func validateQueryParams(patternQuery, inputQuery url.Values) bool {
|
||||
if len(patternQuery) != len(inputQuery) {
|
||||
return false
|
||||
}
|
||||
|
||||
for patternKey, patternValues := range patternQuery {
|
||||
inputValues, exists := inputQuery[patternKey]
|
||||
if !exists {
|
||||
return false, nil
|
||||
return false
|
||||
}
|
||||
|
||||
if len(patternValues) != len(inputValues) {
|
||||
return false, nil
|
||||
return false
|
||||
}
|
||||
|
||||
for i := range patternValues {
|
||||
matched, err := path.Match(patternValues[i], inputValues[i])
|
||||
if err != nil || !matched {
|
||||
return false, err
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// matchPath matches the input path against the pattern with wildcard support
|
||||
// Supported wildcards:
|
||||
//
|
||||
// '*' matches any sequence of characters except '/'
|
||||
// '**' matches any sequence of characters including '/'
|
||||
func matchPath(pattern string, input string) (matches bool, err error) {
|
||||
var regexPattern strings.Builder
|
||||
regexPattern.WriteString("^")
|
||||
|
||||
runes := []rune(pattern)
|
||||
n := len(runes)
|
||||
|
||||
for i := 0; i < n; {
|
||||
switch runes[i] {
|
||||
case '*':
|
||||
// Check if it's a ** (globstar)
|
||||
if i+1 < n && runes[i+1] == '*' {
|
||||
// globstar = .* (match slashes too)
|
||||
regexPattern.WriteString(".*")
|
||||
i += 2
|
||||
} else {
|
||||
// single * = [^/]* (no slash)
|
||||
regexPattern.WriteString(`[^/]*`)
|
||||
i++
|
||||
}
|
||||
default:
|
||||
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
regexPattern.WriteString("$")
|
||||
|
||||
matched, err := regexp.MatchString(regexPattern.String(), input)
|
||||
return matched, err
|
||||
}
|
||||
|
||||
// splitParts splits the URL into parts by special characters and returns the path separately
|
||||
func splitParts(s string) (parts []string, path string) {
|
||||
split := func(r rune) bool {
|
||||
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
|
||||
}
|
||||
|
||||
pathStart := -1
|
||||
|
||||
// Look for scheme:// first
|
||||
if i := strings.Index(s, "://"); i >= 0 {
|
||||
// Look for the next slash after scheme://
|
||||
rest := s[i+3:]
|
||||
if j := strings.IndexRune(rest, '/'); j >= 0 {
|
||||
pathStart = i + 3 + j
|
||||
}
|
||||
} else {
|
||||
// Otherwise, first slash is path start
|
||||
pathStart = strings.IndexRune(s, '/')
|
||||
}
|
||||
|
||||
if pathStart >= 0 {
|
||||
path = s[pathStart:]
|
||||
base := s[:pathStart]
|
||||
parts = strings.FieldsFunc(base, split)
|
||||
} else {
|
||||
parts = strings.FieldsFunc(s, split)
|
||||
path = ""
|
||||
}
|
||||
|
||||
return parts, path
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -7,6 +7,142 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestValidateCallbackURLPattern(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
shouldError bool
|
||||
}{
|
||||
{
|
||||
name: "exact URL",
|
||||
pattern: "https://example.com/callback",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard scheme",
|
||||
pattern: "*://example.com/callback",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard port",
|
||||
pattern: "https://example.com:*/callback",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "partial wildcard port",
|
||||
pattern: "https://example.com:80*/callback",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard userinfo",
|
||||
pattern: "https://user:*@example.com/callback",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "glob wildcard",
|
||||
pattern: "*",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "relative URL",
|
||||
pattern: "/callback",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "missing scheme separator",
|
||||
pattern: "https//example.com/callback",
|
||||
shouldError: true,
|
||||
},
|
||||
{
|
||||
name: "malformed wildcard host glob",
|
||||
pattern: "https://exa[mple.com/callback",
|
||||
shouldError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateCallbackURLPattern(tt.pattern)
|
||||
if tt.shouldError {
|
||||
require.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeToURLPatternStandard(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "exact URL unchanged",
|
||||
input: "https://example.com/callback",
|
||||
expected: "https://example.com/callback",
|
||||
},
|
||||
{
|
||||
name: "single wildcard path segment converted to named parameter",
|
||||
input: "https://example.com/api/*/callback",
|
||||
expected: "https://example.com/api/:p5/callback",
|
||||
},
|
||||
{
|
||||
name: "single wildcard in path suffix converted to named parameter",
|
||||
input: "https://example.com/test*",
|
||||
expected: "https://example.com/test:p5",
|
||||
},
|
||||
{
|
||||
name: "globstar converted to single asterisk",
|
||||
input: "https://example.com/**/callback",
|
||||
expected: "https://example.com/*/callback",
|
||||
},
|
||||
{
|
||||
name: "mixed globstar and single wildcard conversion",
|
||||
input: "https://example.com/**/v1/**/callback/*",
|
||||
expected: "https://example.com/*/v1/*/callback/:p19",
|
||||
},
|
||||
{
|
||||
name: "URL without path unchanged",
|
||||
input: "https://example.com",
|
||||
expected: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "relative path conversion",
|
||||
input: "/foo/*/bar",
|
||||
expected: "/foo/:p5/bar",
|
||||
},
|
||||
{
|
||||
name: "wildcard in hostname is not normalized by this function",
|
||||
input: "https://*.example.com/callback",
|
||||
expected: "https://*.example.com/callback",
|
||||
},
|
||||
{
|
||||
name: "IPv6 hostname escapes all colons inside address",
|
||||
input: "https://[2001:db8:1:1::a:1]/callback",
|
||||
expected: "https://[2001\\:db8\\:1\\:1\\:\\:a\\:1]/callback",
|
||||
},
|
||||
{
|
||||
name: "IPv6 hostname with port escapes only address colons",
|
||||
input: "https://[::1]:8080/callback",
|
||||
expected: "https://[\\:\\:1]:8080/callback",
|
||||
},
|
||||
{
|
||||
name: "wildcard in query is converted when query is part of input",
|
||||
input: "https://example.com/callback?code=*",
|
||||
expected: "https://example.com/callback?code=:p15",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.expected, normalizeToURLPatternStandard(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchCallbackURL(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -27,6 +163,18 @@ func TestMatchCallbackURL(t *testing.T) {
|
||||
"https://example.com/callback",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"exact match - IPv4",
|
||||
"https://10.1.0.1/callback",
|
||||
"https://10.1.0.1/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"exact match - IPv6",
|
||||
"https://[2001:db8:1:1::a:1]/callback",
|
||||
"https://[2001:db8:1:1::a:1]/callback",
|
||||
true,
|
||||
},
|
||||
|
||||
// Scheme
|
||||
{
|
||||
@@ -111,6 +259,30 @@ func TestMatchCallbackURL(t *testing.T) {
|
||||
"https://example.com:8080/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"wildcard port - IPv4",
|
||||
"https://10.1.0.1:*/callback",
|
||||
"https://10.1.0.1:8080/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"partial wildcard in port prefix - IPv4",
|
||||
"https://10.1.0.1:80*/callback",
|
||||
"https://10.1.0.1:8080/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"wildcard port - IPv6",
|
||||
"https://[2001:db8:1:1::a:1]:*/callback",
|
||||
"https://[2001:db8:1:1::a:1]:8080/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"partial wildcard in port prefix - IPv6",
|
||||
"https://[2001:db8:1:1::a:1]:80*/callback",
|
||||
"https://[2001:db8:1:1::a:1]:8080/callback",
|
||||
true,
|
||||
},
|
||||
|
||||
// Path
|
||||
{
|
||||
@@ -131,6 +303,18 @@ func TestMatchCallbackURL(t *testing.T) {
|
||||
"https://example.com/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"wildcard entire path - IPv4",
|
||||
"https://10.1.0.1/*",
|
||||
"https://10.1.0.1/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"wildcard entire path - IPv6",
|
||||
"https://[2001:db8:1:1::a:1]/*",
|
||||
"https://[2001:db8:1:1::a:1]/callback",
|
||||
true,
|
||||
},
|
||||
{
|
||||
"partial wildcard in path prefix",
|
||||
"https://example.com/test*",
|
||||
@@ -187,12 +371,6 @@ func TestMatchCallbackURL(t *testing.T) {
|
||||
"https://example.com/callback",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"unexpected credentials",
|
||||
"https://example.com/callback",
|
||||
"https://user:pass@example.com/callback",
|
||||
false,
|
||||
},
|
||||
{
|
||||
"wildcard password",
|
||||
"https://user:*@example.com/callback",
|
||||
@@ -347,7 +525,7 @@ func TestMatchCallbackURL(t *testing.T) {
|
||||
"backslash instead of forward slash",
|
||||
"https://example.com/callback",
|
||||
"https://example.com\\callback",
|
||||
false,
|
||||
true,
|
||||
},
|
||||
{
|
||||
"double slash in hostname (protocol smuggling)",
|
||||
@@ -370,10 +548,11 @@ func TestMatchCallbackURL(t *testing.T) {
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
matches, err := matchCallbackURL(tt.pattern, tt.input)
|
||||
require.NoError(t, err, tt.name)
|
||||
assert.Equal(t, tt.shouldMatch, matches, tt.name)
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches, err := matchCallbackURL(tt.pattern, tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.shouldMatch, matches)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,14 +586,21 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with dynamic port",
|
||||
name: "IPv6 loopback with dynamic port - exact match",
|
||||
urls: []string{"http://[::1]/callback"},
|
||||
inputCallbackURL: "http://[::1]:8080/callback",
|
||||
expectedURL: "http://[::1]:8080/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with wildcard path",
|
||||
name: "IPv6 loopback with same port - exact match",
|
||||
urls: []string{"http://[::1]:8080/callback"},
|
||||
inputCallbackURL: "http://[::1]:8080/callback",
|
||||
expectedURL: "http://[::1]:8080/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with path match",
|
||||
urls: []string{"http://[::1]/auth/*"},
|
||||
inputCallbackURL: "http://[::1]:8080/auth/callback",
|
||||
expectedURL: "http://[::1]:8080/auth/callback",
|
||||
@@ -441,6 +627,20 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
||||
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "loopback with path port",
|
||||
urls: []string{"http://127.0.0.1:*/auth/callback"},
|
||||
inputCallbackURL: "http://127.0.0.1:3000/auth/callback",
|
||||
expectedURL: "http://127.0.0.1:3000/auth/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with path port",
|
||||
urls: []string{"http://[::1]:*/auth/callback"},
|
||||
inputCallbackURL: "http://[::1]:3000/auth/callback",
|
||||
expectedURL: "http://[::1]:3000/auth/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "loopback with path mismatch",
|
||||
urls: []string{"http://127.0.0.1/callback"},
|
||||
@@ -484,6 +684,76 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoopbackURLWithWildcardPort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
output string
|
||||
}{
|
||||
{
|
||||
name: "localhost http with port strips port",
|
||||
input: "http://localhost:3000/callback",
|
||||
output: "http://localhost/callback",
|
||||
},
|
||||
{
|
||||
name: "localhost http without port stays same",
|
||||
input: "http://localhost/callback",
|
||||
output: "http://localhost/callback",
|
||||
},
|
||||
{
|
||||
name: "IPv4 loopback with port strips port",
|
||||
input: "http://127.0.0.1:8080/callback",
|
||||
output: "http://127.0.0.1/callback",
|
||||
},
|
||||
{
|
||||
name: "IPv4 loopback without port stays same",
|
||||
input: "http://127.0.0.1/callback",
|
||||
output: "http://127.0.0.1/callback",
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback with port strips port and keeps brackets",
|
||||
input: "http://[::1]:8080/callback",
|
||||
output: "http://[::1]/callback",
|
||||
},
|
||||
{
|
||||
name: "IPv6 loopback preserves path query and fragment",
|
||||
input: "http://[::1]:8080/auth/callback?code=123#state",
|
||||
output: "http://[::1]/auth/callback?code=123#state",
|
||||
},
|
||||
{
|
||||
name: "https loopback returns empty",
|
||||
input: "https://127.0.0.1:8080/callback",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "non loopback host returns empty",
|
||||
input: "http://example.com:8080/callback",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "non loopback IP returns empty",
|
||||
input: "http://192.168.1.10:8080/callback",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "malformed URL returns empty",
|
||||
input: "http://[::1:8080/callback",
|
||||
output: "",
|
||||
},
|
||||
{
|
||||
name: "relative URL returns empty",
|
||||
input: "/callback",
|
||||
output: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.output, loopbackURLWithWildcardPort(tt.input))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -553,246 +823,3 @@ func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
input string
|
||||
shouldMatch bool
|
||||
}{
|
||||
// Exact matches
|
||||
{
|
||||
name: "exact match",
|
||||
pattern: "/callback",
|
||||
input: "/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "exact mismatch",
|
||||
pattern: "/callback",
|
||||
input: "/other",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "empty paths",
|
||||
pattern: "",
|
||||
input: "",
|
||||
shouldMatch: true,
|
||||
},
|
||||
|
||||
// Single wildcard (*)
|
||||
{
|
||||
name: "single wildcard matches segment",
|
||||
pattern: "/api/*/callback",
|
||||
input: "/api/v1/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "single wildcard doesn't match multiple segments",
|
||||
pattern: "/api/*/callback",
|
||||
input: "/api/v1/v2/callback",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "single wildcard at end",
|
||||
pattern: "/callback/*",
|
||||
input: "/callback/test",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "single wildcard at start",
|
||||
pattern: "/*/callback",
|
||||
input: "/api/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "multiple single wildcards",
|
||||
pattern: "/*/test/*",
|
||||
input: "/api/test/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "partial wildcard prefix",
|
||||
pattern: "/test*",
|
||||
input: "/testing",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "partial wildcard suffix",
|
||||
pattern: "/*-callback",
|
||||
input: "/oauth-callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "partial wildcard middle",
|
||||
pattern: "/api-*-v1",
|
||||
input: "/api-internal-v1",
|
||||
shouldMatch: true,
|
||||
},
|
||||
|
||||
// Double wildcard (**)
|
||||
{
|
||||
name: "double wildcard matches multiple segments",
|
||||
pattern: "/api/**/callback",
|
||||
input: "/api/v1/v2/v3/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "double wildcard matches single segment",
|
||||
pattern: "/api/**/callback",
|
||||
input: "/api/v1/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "double wildcard doesn't match when pattern has extra slashes",
|
||||
pattern: "/api/**/callback",
|
||||
input: "/api/callback",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "double wildcard at end",
|
||||
pattern: "/api/**",
|
||||
input: "/api/v1/v2/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "double wildcard in middle",
|
||||
pattern: "/api/**/v2/**/callback",
|
||||
input: "/api/v1/v2/v3/v4/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
|
||||
// Complex patterns
|
||||
{
|
||||
name: "mix of single and double wildcards",
|
||||
pattern: "/*/api/**/callback",
|
||||
input: "/app/api/v1/v2/callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard with special characters",
|
||||
pattern: "/callback-*",
|
||||
input: "/callback-123",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "path with query-like string (no special handling)",
|
||||
pattern: "/callback?code=*",
|
||||
input: "/callback?code=abc",
|
||||
shouldMatch: true,
|
||||
},
|
||||
|
||||
// Edge cases
|
||||
{
|
||||
name: "single wildcard matches empty segment",
|
||||
pattern: "/api/*/callback",
|
||||
input: "/api//callback",
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "pattern longer than input",
|
||||
pattern: "/api/v1/callback",
|
||||
input: "/api",
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "input longer than pattern",
|
||||
pattern: "/api",
|
||||
input: "/api/v1/callback",
|
||||
shouldMatch: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
matches, err := matchPath(tt.pattern, tt.input)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.shouldMatch, matches)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSplitParts(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expectedParts []string
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "simple https URL",
|
||||
input: "https://example.com/callback",
|
||||
expectedParts: []string{"https", "example", "com"},
|
||||
expectedPath: "/callback",
|
||||
},
|
||||
{
|
||||
name: "URL with port",
|
||||
input: "https://example.com:8080/callback",
|
||||
expectedParts: []string{"https", "example", "com", "8080"},
|
||||
expectedPath: "/callback",
|
||||
},
|
||||
{
|
||||
name: "URL with subdomain",
|
||||
input: "https://api.example.com/callback",
|
||||
expectedParts: []string{"https", "api", "example", "com"},
|
||||
expectedPath: "/callback",
|
||||
},
|
||||
{
|
||||
name: "URL with credentials",
|
||||
input: "https://user:pass@example.com/callback",
|
||||
expectedParts: []string{"https", "user", "pass", "example", "com"},
|
||||
expectedPath: "/callback",
|
||||
},
|
||||
{
|
||||
name: "URL without path",
|
||||
input: "https://example.com",
|
||||
expectedParts: []string{"https", "example", "com"},
|
||||
expectedPath: "",
|
||||
},
|
||||
{
|
||||
name: "URL with deep path",
|
||||
input: "https://example.com/api/v1/callback",
|
||||
expectedParts: []string{"https", "example", "com"},
|
||||
expectedPath: "/api/v1/callback",
|
||||
},
|
||||
{
|
||||
name: "URL with path and query",
|
||||
input: "https://example.com/callback?code=123",
|
||||
expectedParts: []string{"https", "example", "com"},
|
||||
expectedPath: "/callback?code=123",
|
||||
},
|
||||
{
|
||||
name: "URL with trailing slash",
|
||||
input: "https://example.com/",
|
||||
expectedParts: []string{"https", "example", "com"},
|
||||
expectedPath: "/",
|
||||
},
|
||||
{
|
||||
name: "URL with multiple subdomains",
|
||||
input: "https://api.v1.staging.example.com/callback",
|
||||
expectedParts: []string{"https", "api", "v1", "staging", "example", "com"},
|
||||
expectedPath: "/callback",
|
||||
},
|
||||
{
|
||||
name: "URL with port and credentials",
|
||||
input: "https://user:pass@example.com:8080/callback",
|
||||
expectedParts: []string{"https", "user", "pass", "example", "com", "8080"},
|
||||
expectedPath: "/callback",
|
||||
},
|
||||
{
|
||||
name: "scheme with authority separator but no slash",
|
||||
input: "http://example.com",
|
||||
expectedParts: []string{"http", "example", "com"},
|
||||
expectedPath: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
parts, path := splitParts(tt.input)
|
||||
assert.Equal(t, tt.expectedParts, parts, "parts mismatch")
|
||||
assert.Equal(t, tt.expectedPath, path, "path mismatch")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
35
backend/internal/utils/networked_filesystem_linux.go
Normal file
35
backend/internal/utils/networked_filesystem_linux.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build linux
|
||||
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// Filesystem magic values from Linux's include/uapi/linux/magic.h, used by statfs(2).
|
||||
const (
|
||||
nfsSuperMagic = 0x6969
|
||||
smbSuperMagic = 0x517b
|
||||
cifsSuperMagic = 0xff534d42
|
||||
fuseSuperMagic = 0x65735546
|
||||
)
|
||||
|
||||
// IsNetworkedFileSystem reports whether path is on a filesystem that is known to be unsafe for SQLite, specifically NFS, SMB/CIFS, or FUSE mounts.
|
||||
func IsNetworkedFileSystem(path string) (bool, error) {
|
||||
var statfs syscall.Statfs_t
|
||||
err := syscall.Statfs(path, &statfs)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("error executing statfs syscall: %w", err)
|
||||
}
|
||||
|
||||
// Statfs_t.Type is arch-dependent (for example, int32 on some systems and int64 on others).
|
||||
// Normalize through uint32 first so signed values still preserve the Linux bit pattern for magic numbers such as CIFS (0xff534d42), then compare in a wide unsigned form.
|
||||
//nolint:gosec
|
||||
switch uint64(uint32(statfs.Type)) {
|
||||
case nfsSuperMagic, smbSuperMagic, cifsSuperMagic, fuseSuperMagic:
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
8
backend/internal/utils/networked_filesystem_nonlinux.go
Normal file
8
backend/internal/utils/networked_filesystem_nonlinux.go
Normal file
@@ -0,0 +1,8 @@
|
||||
//go:build !linux
|
||||
|
||||
package utils
|
||||
|
||||
// IsNetworkedFileSystem returns false on non-Linux systems because this detection is only used for Linux-specific statfs(2) filesystem magic values.
|
||||
func IsNetworkedFileSystem(string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1 +1 @@
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.ApiKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||
@@ -6,6 +6,6 @@ API KEY EXPIRING SOON
|
||||
Warning
|
||||
|
||||
Hello {{.Data.Name}},
|
||||
This is a reminder that your API key {{.Data.APIKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||
This is a reminder that your API key {{.Data.ApiKeyName}} will expire on {{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}.
|
||||
|
||||
Please generate a new API key if you need continued access.{{end}}
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op
|
||||
@@ -0,0 +1,6 @@
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE oidc_authorization_codes DROP COLUMN authentication_method;
|
||||
ALTER TABLE oidc_refresh_tokens DROP COLUMN authentication_method;
|
||||
ALTER TABLE oidc_device_codes DROP COLUMN authentication_method;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE oidc_authorization_codes
|
||||
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE oidc_refresh_tokens
|
||||
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE oidc_device_codes
|
||||
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1 @@
|
||||
-- No-op
|
||||
@@ -0,0 +1,12 @@
|
||||
PRAGMA foreign_keys= OFF;
|
||||
BEGIN;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_webauthn_sessions_expires_at ON webauthn_sessions (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_one_time_access_tokens_expires_at ON one_time_access_tokens (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_authorization_codes_expires_at ON oidc_authorization_codes (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_oidc_refresh_tokens_expires_at ON oidc_refresh_tokens (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_reauthentication_tokens_expires_at ON reauthentication_tokens (expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verification_tokens_expires_at ON email_verification_tokens (expires_at);
|
||||
|
||||
COMMIT;
|
||||
PRAGMA foreign_keys=ON;
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE oidc_authorization_codes DROP COLUMN authentication_method;
|
||||
ALTER TABLE oidc_refresh_tokens DROP COLUMN authentication_method;
|
||||
ALTER TABLE oidc_device_codes DROP COLUMN authentication_method;
|
||||
@@ -0,0 +1,6 @@
|
||||
ALTER TABLE oidc_authorization_codes
|
||||
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE oidc_refresh_tokens
|
||||
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE oidc_device_codes
|
||||
ADD COLUMN authentication_method TEXT NOT NULL DEFAULT '';
|
||||
@@ -40,7 +40,7 @@ RUN VERSION=$(cat /build/.version) \
|
||||
.
|
||||
|
||||
# Stage 3: Production Image
|
||||
FROM alpine
|
||||
FROM alpine:3.23.4
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk add --no-cache curl su-exec
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# This Dockerfile embeds a pre-built binary for the given Linux architecture
|
||||
# Binaries must be built using "./scripts/development/build-binaries.sh --docker-only"
|
||||
|
||||
FROM alpine
|
||||
FROM alpine:3.23.4
|
||||
|
||||
# TARGETARCH can be "amd64" or "arm64"
|
||||
ARG TARGETARCH
|
||||
|
||||
@@ -40,7 +40,7 @@ ApiKeyExpiringEmail.TemplateProps = {
|
||||
...sharedTemplateProps,
|
||||
data: {
|
||||
name: "{{.Data.Name}}",
|
||||
apiKeyName: "{{.Data.APIKeyName}}",
|
||||
apiKeyName: "{{.Data.ApiKeyName}}",
|
||||
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@react-email/components": "1.0.1",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@react-email/preview-server": "5.0.7",
|
||||
"@types/node": "^24.10.4",
|
||||
"@types/react": "^19.2.7",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react-email": "5.0.7",
|
||||
"tsx": "^4.21.0"
|
||||
|
||||
@@ -5,4 +5,5 @@ yarn.lock
|
||||
|
||||
# Compiled files
|
||||
.svelte-kit/
|
||||
build/
|
||||
build/
|
||||
src/lib/paraglide/messages
|
||||
528
frontend/messages/ca.json
Normal file
528
frontend/messages/ca.json
Normal file
@@ -0,0 +1,528 @@
|
||||
{
|
||||
"$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, JPEG or WEBP 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",
|
||||
"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",
|
||||
"webauthn_error_invalid_rp_id": "The configured relying party ID is invalid.",
|
||||
"webauthn_error_invalid_domain": "The configured domain is invalid.",
|
||||
"contact_administrator_to_fix": "Contact your administrator to fix this issue.",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "The operation was not allowed or timed out",
|
||||
"webauthn_not_supported_by_browser": "Passkeys are not supported by this browser. Please use an alternative sign in method.",
|
||||
"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_recent_account_activities": "See your account activities within the configured retention period.",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"approximate_location": "Approximate Location",
|
||||
"ip_address": "IP Address",
|
||||
"device": "Device",
|
||||
"client": "Client",
|
||||
"actor": "Actor",
|
||||
"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.",
|
||||
"manage_this_users_passkeys": "Manage this user's passkeys.",
|
||||
"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.",
|
||||
"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.",
|
||||
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
|
||||
"ldap_disabled_successfully": "LDAP disabled successfully",
|
||||
"ldap_sync_finished": "LDAP sync finished",
|
||||
"client_configuration": "Client Configuration",
|
||||
"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.",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"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",
|
||||
"allowed_user_groups_description": "Select the user groups whose members are allowed to sign in to this client.",
|
||||
"allowed_user_groups_status_unrestricted_description": "No user group restrictions are applied. Any user can sign in to this client.",
|
||||
"unrestrict": "Unrestrict",
|
||||
"restrict": "Restrict",
|
||||
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
|
||||
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
|
||||
"favicon": "Favicon",
|
||||
"light_mode_logo": "Light Mode Logo",
|
||||
"dark_mode_logo": "Dark Mode Logo",
|
||||
"email_logo": "Email 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_recent_account_activities": "View the account activities of all users during the set retention period.",
|
||||
"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. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||
"logout_callback_url_description": "URL(s) provided by your client for logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> are supported.",
|
||||
"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": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||
"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",
|
||||
"ldap_id": "LDAP ID",
|
||||
"reauthentication": "Re-authentication",
|
||||
"clear_filters": "Clear Filters",
|
||||
"default_profile_picture": "Default Profile Picture",
|
||||
"light": "Light",
|
||||
"dark": "Dark",
|
||||
"system": "System",
|
||||
"signup_token_user_groups_description": "Automatically assign these groups to users who sign up using this token.",
|
||||
"allowed_oidc_clients": "Allowed OIDC Clients",
|
||||
"allowed_oidc_clients_description": "Select the OIDC clients that members of this user group are allowed to sign in to.",
|
||||
"unrestrict_oidc_client": "Unrestrict {clientName}",
|
||||
"confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client <b>{clientName}</b>? This will remove all group assignments for this client and any user will be able to sign in.",
|
||||
"allowed_oidc_clients_updated_successfully": "Allowed OIDC clients updated successfully",
|
||||
"yes": "Yes",
|
||||
"no": "No",
|
||||
"restricted": "Restricted",
|
||||
"scim_provisioning": "SCIM Provisioning",
|
||||
"scim_provisioning_description": "SCIM provisioning allows you to automatically provision and deprovision users and groups from your OIDC client. Learn more in the <link href='https://pocket-id.org/docs/configuration/scim'>docs</link>.",
|
||||
"scim_endpoint": "SCIM Endpoint",
|
||||
"scim_token": "SCIM Token",
|
||||
"last_successful_sync_at": "Last successful sync: {time}",
|
||||
"scim_configuration_updated_successfully": "SCIM configuration updated successfully.",
|
||||
"scim_enabled_successfully": "SCIM enabled successfully.",
|
||||
"scim_disabled_successfully": "SCIM disabled successfully.",
|
||||
"disable_scim_provisioning": "Disable SCIM Provisioning",
|
||||
"disable_scim_provisioning_confirm_description": "Are you sure you want to disable SCIM provisioning for <b>{clientName}</b>? This will stop all automatic user and group provisioning and deprovisioning.",
|
||||
"scim_sync_failed": "SCIM sync failed. Check the server logs for more information.",
|
||||
"scim_sync_successful": "The SCIM sync has been completed successfully.",
|
||||
"save_and_sync": "Save and Sync",
|
||||
"scim_save_changes_description": "You have to save the changes before starting a SCIM sync. Do you want to save now?",
|
||||
"scopes": "Scopes",
|
||||
"issuer_url": "Issuer URL",
|
||||
"smtp_field_required_when_other_provided": "Required when any SMTP setting is provided",
|
||||
"smtp_field_required_when_email_enabled": "Required when email notifications are enabled",
|
||||
"renew": "Renew",
|
||||
"renew_api_key": "Renew API Key",
|
||||
"renew_api_key_description": "Renewing the API key will generate a new key. Make sure to update any integrations using this key.",
|
||||
"api_key_renewed": "API key renewed",
|
||||
"app_config_home_page": "Home Page",
|
||||
"app_config_home_page_description": "The page users are redirected to after signing in.",
|
||||
"email_verification_warning": "Verify your email address",
|
||||
"email_verification_warning_description": "Your email address is not verified yet. Please verify it as soon as possible.",
|
||||
"email_verification": "Email Verification",
|
||||
"email_verification_description": "Send a verification email to users when they sign up or change their email address.",
|
||||
"email_verification_success_title": "Email Verified Successfully",
|
||||
"email_verification_success_description": "Your email address has been verified successfully.",
|
||||
"email_verification_error_title": "Email Verification Failed",
|
||||
"mark_as_unverified": "Mark as unverified",
|
||||
"mark_as_verified": "Mark as verified",
|
||||
"email_verification_sent": "Verification email sent successfully.",
|
||||
"emails_verified_by_default": "Emails verified by default",
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet."
|
||||
}
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP adresa",
|
||||
"device": "Zařízení",
|
||||
"client": "Klient",
|
||||
"actor": "Herec",
|
||||
"unknown": "Neznámé",
|
||||
"account_details_updated_successfully": "Účet byl úspěšně aktualizován",
|
||||
"profile_picture_updated_successfully": "Profilový obrázek byl úspěšně aktualizován. Aktualizace může trvat několik minut.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Podrobnosti účtu",
|
||||
"passkeys": "Přístupové klíče",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Spravujte své přístupové klíče, které můžete použít pro ověření.",
|
||||
"manage_this_users_passkeys": "Přehled a správa přístupových klíčů tohoto uživatele.",
|
||||
"add_passkey": "Přidat přístupový klíč",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Vytvořte jednorázový přihlašovací kód pro přihlášení z jiného zařízení bez přístupového klíče.",
|
||||
"create": "Vytvořit",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
|
||||
"authorize": "Autorizovat",
|
||||
"federated_client_credentials": "Údaje o klientovi ve federaci",
|
||||
"federated_client_credentials_description": "Pomocí federovaných přihlašovacích údajů klienta můžete ověřit klienty OIDC pomocí JWT tokenů vydaných třetí stranou.",
|
||||
"federated_client_credentials_description": "Federované klientské přihlašovací údaje umožňují ověřování klientů OIDC bez správy dlouhodobých tajných klíčů. Využívají tokeny JWT vydané třetími stranami pro klientská tvrzení, např. tokeny identity pracovního zatížení.",
|
||||
"add_federated_client_credential": "Přidat údaje federovaného klienta",
|
||||
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
|
||||
"oidc_allowed_group_count": "Počet povolených skupin",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Označit jako ověřené",
|
||||
"email_verification_sent": "Ověřovací e-mail byl úspěšně odeslán.",
|
||||
"emails_verified_by_default": "E-maily ověřené ve výchozím nastavení",
|
||||
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené."
|
||||
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené.",
|
||||
"user_has_no_passkeys_yet": "Tento uživatel zatím nemá žádné přístupové klíče."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP-adresse",
|
||||
"device": "Enhed",
|
||||
"client": "Klient",
|
||||
"actor": "Skuespiller",
|
||||
"unknown": "Ukendt",
|
||||
"account_details_updated_successfully": "Kontodetaljer blev opdateret",
|
||||
"profile_picture_updated_successfully": "Profilbillede opdateret. Det kan tage et par minutter før ændringen vises.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Kontooplysninger",
|
||||
"passkeys": "Adgangsnøgler",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administrér dine adgangsnøgler, som du kan bruge til at godkende dig selv.",
|
||||
"manage_this_users_passkeys": "Administrer denne brugers adgangskoder.",
|
||||
"add_passkey": "Tilføj adgangsnøgle",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Opret en engangskode for at logge ind fra en anden enhed uden en adgangsnøgle.",
|
||||
"create": "Opret",
|
||||
@@ -356,7 +358,7 @@
|
||||
"login_code_email_success": "Loginkoden er sendt til brugeren.",
|
||||
"send_email": "Send e-mail",
|
||||
"show_code": "Vis kode",
|
||||
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Jokertegn</link> understøttes.",
|
||||
"callback_url_description": "URL(er) angivet af din klient. Tilføjes automatisk, hvis feltet efterlades tomt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||
"logout_callback_url_description": "URL(er) angivet af din klient til logout. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Wildcards</link> understøttes.",
|
||||
"api_key_expiration": "Udløb af API-nøgle",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Send en e-mail til brugeren, når deres API-nøgle er ved at udløbe.",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Indtast koden, der blev vist i det forrige trin.",
|
||||
"authorize": "Godkend",
|
||||
"federated_client_credentials": "Federated klientlegitimationsoplysninger",
|
||||
"federated_client_credentials_description": "Ved hjælp af federated klientlegitimationsoplysninger kan du godkende OIDC-klienter med JWT-tokens udstedt af tredjepartsudbydere.",
|
||||
"federated_client_credentials_description": "Federerede klientlegitimationsoplysninger gør det muligt at autentificere OIDC-klienter uden at skulle administrere langvarige hemmeligheder. De udnytter JWT-tokens udstedt af tredjepartsmyndigheder til klientpåstande, f.eks. identitetstokens for arbejdsbelastning.",
|
||||
"add_federated_client_credential": "Tilføj federated klientlegitimation",
|
||||
"add_another_federated_client_credential": "Tilføj endnu en federated klientlegitimation",
|
||||
"oidc_allowed_group_count": "Tilladt antal grupper",
|
||||
@@ -446,7 +448,7 @@
|
||||
"no_apps_available": "Ingen apps tilgængelige",
|
||||
"contact_your_administrator_for_app_access": "Kontakt din administrator for at få adgang til applikationer.",
|
||||
"launch": "Start",
|
||||
"client_launch_url": "Kundens lancerings-URL",
|
||||
"client_launch_url": "Start-URL til klient",
|
||||
"client_launch_url_description": "Den URL, der åbnes, når en bruger starter appen fra siden Mine apps.",
|
||||
"client_name_description": "Navnet på den klient, der vises i Pocket ID-brugergrænsefladen.",
|
||||
"revoke_access": "Tilbagekald adgang",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Marker som verificeret",
|
||||
"email_verification_sent": "Bekræftelses-e-mail sendt med succes.",
|
||||
"emails_verified_by_default": "E-mails verificeret som standard",
|
||||
"emails_verified_by_default_description": "Når denne funktion er aktiveret, vil brugernes e-mailadresser som standard blive markeret som verificerede ved tilmelding eller når deres e-mailadresse ændres."
|
||||
"emails_verified_by_default_description": "Når denne funktion er aktiveret, vil brugernes e-mailadresser som standard blive markeret som verificerede ved tilmelding eller når deres e-mailadresse ændres.",
|
||||
"user_has_no_passkeys_yet": "Denne bruger har endnu ingen adgangskoder."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP-Adresse",
|
||||
"device": "Gerät",
|
||||
"client": "Client",
|
||||
"actor": "Schauspieler",
|
||||
"unknown": "unbekannt",
|
||||
"account_details_updated_successfully": "Kontodetails erfolgreich aktualisiert",
|
||||
"profile_picture_updated_successfully": "Profilbild erfolgreich aktualisiert. Die Aktualisierung kann einige Minuten dauern.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Kontodetails",
|
||||
"passkeys": "Passkeys",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Verwalte deine Passkeys, mit denen du dich authentifizieren kannst.",
|
||||
"manage_this_users_passkeys": "Verwalte die Passwörter dieses Benutzers.",
|
||||
"add_passkey": "Passkey hinzufügen",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Erzeuge einen einmaligen Anmeldecode, um dich ohne Passkey von einem anderen Gerät aus anzumelden.",
|
||||
"create": "Erzeugen",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Gib den Code ein, der im vorherigen Schritt angezeigt wurde.",
|
||||
"authorize": "Autorisieren",
|
||||
"federated_client_credentials": "Federated Client Credentials",
|
||||
"federated_client_credentials_description": "Mit Hilfe von Verbund-Client-Anmeldeinformationen kannst du OIDC-Clients mit JWT-Tokens authentifizieren, die von Drittanbietern ausgestellt wurden.",
|
||||
"federated_client_credentials_description": "Mit föderierten Client-Anmeldeinfos kann man OIDC-Clients authentifizieren, ohne sich um langlebige Geheimnisse kümmern zu müssen. Sie nutzen JWT-Token, die von Drittanbietern für Client-Assertions ausgestellt werden, z. B. Workload-Identitätstoken.",
|
||||
"add_federated_client_credential": "Föderierte Client-Anmeldeinfos hinzufügen",
|
||||
"add_another_federated_client_credential": "Weitere Anmeldeinformationen für einen Verbundclient hinzufügen",
|
||||
"oidc_allowed_group_count": "Erlaubte Gruppenanzahl",
|
||||
@@ -500,7 +502,7 @@
|
||||
"scim_sync_successful": "Die SCIM-Synchronisierung ist erfolgreich abgeschlossen worden.",
|
||||
"save_and_sync": "Speichern und synchronisieren",
|
||||
"scim_save_changes_description": "Du musst die Änderungen speichern, bevor du eine SCIM-Synchronisierung startest. Willst du jetzt speichern?",
|
||||
"scopes": "Kopfsuchgeräte",
|
||||
"scopes": "Scopes",
|
||||
"issuer_url": "Aussteller-URL",
|
||||
"smtp_field_required_when_other_provided": "Muss angegeben werden, wenn SMTP-Einstellungen gemacht werden",
|
||||
"smtp_field_required_when_email_enabled": "Muss aktiviert sein, wenn du E-Mail-Benachrichtigungen nutzen willst.",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Als verifiziert markieren",
|
||||
"email_verification_sent": "Bestätigungs-E-Mail erfolgreich verschickt.",
|
||||
"emails_verified_by_default": "E-Mails sind standardmäßig verifiziert",
|
||||
"emails_verified_by_default_description": "Wenn diese Option aktiviert ist, werden die E-Mail-Adressen der Nutzer bei der Anmeldung oder bei einer Änderung ihrer E-Mail-Adresse standardmäßig als verifiziert markiert."
|
||||
"emails_verified_by_default_description": "Wenn diese Option aktiviert ist, werden die E-Mail-Adressen der Nutzer bei der Anmeldung oder bei einer Änderung ihrer E-Mail-Adresse standardmäßig als verifiziert markiert.",
|
||||
"user_has_no_passkeys_yet": "Dieser Benutzer hat noch keine Passwörter."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP Address",
|
||||
"device": "Device",
|
||||
"client": "Client",
|
||||
"actor": "Actor",
|
||||
"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.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"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.",
|
||||
"manage_this_users_passkeys": "Manage this user's passkeys.",
|
||||
"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",
|
||||
@@ -365,7 +367,7 @@
|
||||
"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.",
|
||||
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||
"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",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Mark as verified",
|
||||
"email_verification_sent": "Verification email sent successfully.",
|
||||
"emails_verified_by_default": "Emails verified by default",
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "Dirección IP",
|
||||
"device": "Dispositivo",
|
||||
"client": "Cliente",
|
||||
"actor": "Actor",
|
||||
"unknown": "Desconocido",
|
||||
"account_details_updated_successfully": "Detalles de la cuenta actualizados exitosamente",
|
||||
"profile_picture_updated_successfully": "Imagen de perfil actualizada correctamente. Puede tardar unos minutos en actualizarse.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Detalles de la cuenta",
|
||||
"passkeys": "Claves de acceso",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Administra las claves de acceso que te permiten autenticarte.",
|
||||
"manage_this_users_passkeys": "Gestiona las claves de acceso de este usuario.",
|
||||
"add_passkey": "Añade una clave de acceso",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un código de inicio de sesión único para iniciar sesión desde un dispositivo diferente sin una clave.",
|
||||
"create": "Crear",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Introduce el código que se mostró en el paso anterior.",
|
||||
"authorize": "Autorizar",
|
||||
"federated_client_credentials": "Credenciales de cliente federadas",
|
||||
"federated_client_credentials_description": "Mediante credenciales de cliente federadas, puedes autenticar clientes OIDC utilizando tokens JWT emitidos por autoridades de terceros.",
|
||||
"federated_client_credentials_description": "Las credenciales de cliente federadas permiten autenticar clientes OIDC sin gestionar secretos de larga duración. Aprovechan los tokens JWT emitidos por autoridades externas para las afirmaciones de los clientes, por ejemplo, tokens de identidad de carga de trabajo.",
|
||||
"add_federated_client_credential": "Añadir credenciales de cliente federado",
|
||||
"add_another_federated_client_credential": "Añadir otra credencial de cliente federado",
|
||||
"oidc_allowed_group_count": "Recuento de grupos permitidos",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Marcar como verificado",
|
||||
"email_verification_sent": "El correo electrónico de verificación se ha enviado correctamente.",
|
||||
"emails_verified_by_default": "Correos electrónicos verificados de forma predeterminada",
|
||||
"emails_verified_by_default_description": "Cuando esta opción está activada, las direcciones de correo electrónico de los usuarios se marcarán como verificadas de forma predeterminada al registrarse o cuando se modifique su dirección de correo electrónico."
|
||||
"emails_verified_by_default_description": "Cuando esta opción está activada, las direcciones de correo electrónico de los usuarios se marcarán como verificadas de forma predeterminada al registrarse o cuando se modifique su dirección de correo electrónico.",
|
||||
"user_has_no_passkeys_yet": "Este usuario aún no tiene claves de acceso."
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"my_account": "My Account",
|
||||
"my_account": "Minu konto",
|
||||
"logout": "Logout",
|
||||
"confirm": "Confirm",
|
||||
"docs": "Docs",
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP Address",
|
||||
"device": "Device",
|
||||
"client": "Client",
|
||||
"actor": "Näitleja",
|
||||
"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.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"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.",
|
||||
"manage_this_users_passkeys": "Haldage selle kasutaja paroolivõtmeid.",
|
||||
"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",
|
||||
@@ -365,7 +367,7 @@
|
||||
"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.",
|
||||
"federated_client_credentials_description": "Föderatiivsed kliendi autentimisandmed võimaldavad OIDC-kliente autentida ilma pikaajalisi salajasi andmeid haldamata. Need kasutavad kolmandate osapoolte poolt väljastatud JWT-tokeneid kliendi kinnituste jaoks, nt töökoormuse identiteeditokeneid.",
|
||||
"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",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Mark as verified",
|
||||
"email_verification_sent": "Verification email sent successfully.",
|
||||
"emails_verified_by_default": "Emails verified by default",
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||
"user_has_no_passkeys_yet": "Sellel kasutajal pole veel paroolivõtmeid."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP-osoite",
|
||||
"device": "Laite",
|
||||
"client": "Asiakas",
|
||||
"actor": "Näyttelijä",
|
||||
"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.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Tilitiedot",
|
||||
"passkeys": "Pääsyavaimet",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Hallitse pääsyavaimiasi, joita voit käyttää tunnistautumiseen.",
|
||||
"manage_this_users_passkeys": "Hallitse tämän käyttäjän salasanat.",
|
||||
"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",
|
||||
@@ -365,7 +367,7 @@
|
||||
"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.",
|
||||
"federated_client_credentials_description": "Yhdistetyt asiakastunnistetiedot mahdollistavat OIDC-asiakkaiden todentamisen ilman pitkäaikaisten salaisuuksien hallintaa. Ne hyödyntävät kolmansien osapuolten viranomaisten myöntämiä JWT-tunnuksia asiakastodistuksiin, esimerkiksi työkuorman tunnistetunnuksiin.",
|
||||
"add_federated_client_credential": "Lisää federoitu asiakastunnus",
|
||||
"add_another_federated_client_credential": "Lisää toinen federoitu asiakastunnus",
|
||||
"oidc_allowed_group_count": "Sallittujen ryhmien määrä",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Merkitse vahvistetuksi",
|
||||
"email_verification_sent": "Vahvistussähköposti lähetetty onnistuneesti.",
|
||||
"emails_verified_by_default": "Sähköpostit vahvistettu oletuksena",
|
||||
"emails_verified_by_default_description": "Kun tämä toiminto on käytössä, käyttäjien sähköpostiosoitteet merkitään oletusarvoisesti vahvistetuiksi rekisteröitymisen yhteydessä tai kun heidän sähköpostiosoitteensa muuttuu."
|
||||
"emails_verified_by_default_description": "Kun tämä toiminto on käytössä, käyttäjien sähköpostiosoitteet merkitään oletusarvoisesti vahvistetuiksi rekisteröitymisen yhteydessä tai kun heidän sähköpostiosoitteensa muuttuu.",
|
||||
"user_has_no_passkeys_yet": "Tällä käyttäjällä ei ole vielä salasanakoodeja."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "Adresse IP",
|
||||
"device": "Périphérique",
|
||||
"client": "Application",
|
||||
"actor": "Acteur",
|
||||
"unknown": "Indisponible",
|
||||
"account_details_updated_successfully": "Les informations du compte ont été mises à jour avec succès",
|
||||
"profile_picture_updated_successfully": "La photo de profil a été mise à jour avec succès. La mise à jour peut prendre quelques minutes.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Paramètres du compte",
|
||||
"passkeys": "Clés d'accès",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Gérez vos clés d'accès que vous pouvez utiliser pour vous authentifier.",
|
||||
"manage_this_users_passkeys": "Gérer les mots de passe de cet utilisateur.",
|
||||
"add_passkey": "Ajouter une clé d'accès",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Créez un code de connexion unique pour vous connecter depuis un autre appareil sans mot de passe.",
|
||||
"create": "Créer",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Entrez le code affiché à l'étape précédente.",
|
||||
"authorize": "Autoriser",
|
||||
"federated_client_credentials": "Identifiants client fédérés",
|
||||
"federated_client_credentials_description": "Avec des identifiants clients fédérés, vous pouvez authentifier des clients OIDC avec des tokens JWT émis par des autorités tierces.",
|
||||
"federated_client_credentials_description": "Les informations d'identification client fédérées permettent d'authentifier les clients OIDC sans avoir à gérer des secrets à long terme. Elles utilisent des jetons JWT émis par des autorités tierces pour les assertions client, par exemple des jetons d'identité de charge de travail.",
|
||||
"add_federated_client_credential": "Ajouter un identifiant client fédéré",
|
||||
"add_another_federated_client_credential": "Ajouter un autre identifiant client fédéré",
|
||||
"oidc_allowed_group_count": "Nombre de groupes autorisés",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Marquer comme vérifié",
|
||||
"email_verification_sent": "L'e-mail de vérification a été envoyé sans problème.",
|
||||
"emails_verified_by_default": "E-mails vérifiés par défaut",
|
||||
"emails_verified_by_default_description": "Quand cette option est activée, les adresses e-mail des utilisateurs seront marquées comme vérifiées par défaut lors de leur inscription ou quand ils changent d'adresse e-mail."
|
||||
"emails_verified_by_default_description": "Quand cette option est activée, les adresses e-mail des utilisateurs seront marquées comme vérifiées par défaut lors de leur inscription ou quand ils changent d'adresse e-mail.",
|
||||
"user_has_no_passkeys_yet": "Cet utilisateur n'a pas encore de mots de passe."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "Indirizzo IP",
|
||||
"device": "Dispositivo",
|
||||
"client": "Client",
|
||||
"actor": "Autore",
|
||||
"unknown": "Sconosciuto",
|
||||
"account_details_updated_successfully": "Dettagli dell'account aggiornati con successo",
|
||||
"profile_picture_updated_successfully": "Immagine del profilo aggiornata con successo. Potrebbero essere necessari alcuni minuti per l'aggiornamento.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Dettagli account",
|
||||
"passkeys": "Passkey",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Gestisci le tue passkey che puoi utilizzare per autenticarti.",
|
||||
"manage_this_users_passkeys": "Gestisci le chiavi di accesso di questo utente.",
|
||||
"add_passkey": "Aggiungi Passkey",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Crea un codice di accesso monouso per accedere da un dispositivo diverso senza una passkey.",
|
||||
"create": "Crea",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Inserisci il codice visualizzato nel passaggio precedente.",
|
||||
"authorize": "Autorizza",
|
||||
"federated_client_credentials": "Identità Federate",
|
||||
"federated_client_credentials_description": "Utilizzando identità federate, è possibile autenticare i client OIDC utilizzando i token JWT emessi da autorità di terze parti.",
|
||||
"federated_client_credentials_description": "Le credenziali client federate ti permettono di autenticare i client OIDC senza dover gestire segreti a lungo termine. Usano i token JWT rilasciati da autorità terze per le asserzioni dei client, tipo i token di identità del carico di lavoro.",
|
||||
"add_federated_client_credential": "Aggiungi Identità Federata",
|
||||
"add_another_federated_client_credential": "Aggiungi un'altra identità federata",
|
||||
"oidc_allowed_group_count": "Numero Gruppi Consentiti",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Contrassegna come verificato",
|
||||
"email_verification_sent": "Email di conferma inviata con successo.",
|
||||
"emails_verified_by_default": "Email verificate di default",
|
||||
"emails_verified_by_default_description": "Quando questa opzione è attiva, gli indirizzi email degli utenti saranno automaticamente contrassegnati come verificati al momento della registrazione o quando cambiano il loro indirizzo email."
|
||||
"emails_verified_by_default_description": "Quando questa opzione è attiva, gli indirizzi email degli utenti saranno automaticamente contrassegnati come verificati al momento della registrazione o quando cambiano il loro indirizzo email.",
|
||||
"user_has_no_passkeys_yet": "Questo utente non ha ancora nessuna chiave di accesso."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IPアドレス",
|
||||
"device": "デバイス",
|
||||
"client": "クライアント",
|
||||
"actor": "俳優",
|
||||
"unknown": "不明",
|
||||
"account_details_updated_successfully": "アカウントの詳細が正常に更新されました",
|
||||
"profile_picture_updated_successfully": "プロフィール画像が正常に更新されました。反映まで数分かかる場合があります。",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "アカウントの詳細",
|
||||
"passkeys": "パスキー",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "認証に使用するパスキーを管理します。",
|
||||
"manage_this_users_passkeys": "このユーザーのパスキーを管理します。",
|
||||
"add_passkey": "パスキーを追加",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "パスキーなしで別のデバイスからサインインするためのワンタイムログインコードを作成します。",
|
||||
"create": "作成",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "前のステップで表示されたコードを入力してください。",
|
||||
"authorize": "Authorize",
|
||||
"federated_client_credentials": "連携クライアントの資格情報",
|
||||
"federated_client_credentials_description": "Using federated client credentials, you can authenticate OIDC clients using JWT tokens issued by third-party authorities.",
|
||||
"federated_client_credentials_description": "フェデレーテッドクライアント認証情報は、長期にわたるシークレットを管理せずにOIDCクライアントを認証することを可能にします。これらは、クライアントアサーション(例:ワークロードIDトークン)のためにサードパーティ機関が発行するJWTトークンを活用します。",
|
||||
"add_federated_client_credential": "Add Federated Client Credential",
|
||||
"add_another_federated_client_credential": "Add another federated client credential",
|
||||
"oidc_allowed_group_count": "許可されたグループ数",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "確認済みとしてマークする",
|
||||
"email_verification_sent": "確認メールが正常に送信されました。",
|
||||
"emails_verified_by_default": "メールはデフォルトで検証済み",
|
||||
"emails_verified_by_default_description": "有効化すると、ユーザーが登録時またはメールアドレスを変更した際に、デフォルトでメールアドレスが確認済みとしてマークされます。"
|
||||
"emails_verified_by_default_description": "有効化すると、ユーザーが登録時またはメールアドレスを変更した際に、デフォルトでメールアドレスが確認済みとしてマークされます。",
|
||||
"user_has_no_passkeys_yet": "このユーザーにはまだパスキーがありません。"
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP 주소",
|
||||
"device": "기기",
|
||||
"client": "클라이언트",
|
||||
"actor": "배우",
|
||||
"unknown": "알 수 없음",
|
||||
"account_details_updated_successfully": "계정 정보가 성공적으로 변경되었습니다",
|
||||
"profile_picture_updated_successfully": "프로필 사진이 성공적으로 변경되었습니다. 변경 적용까지 몇 분 정도 걸릴 수 있습니다.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "계정 세부 사항",
|
||||
"passkeys": "패스키",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "사용자 인증에 사용하는 패스키를 관리하세요.",
|
||||
"manage_this_users_passkeys": "이 사용자의 패스키를 관리합니다.",
|
||||
"add_passkey": "패스키 추가",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "패스키 없이 다른 기기에서 로그인하기 위한 일회용 로그인 코드를 생성합니다.",
|
||||
"create": "생성",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "이전 단계에 표시된 코드를 입력하세요.",
|
||||
"authorize": "승인",
|
||||
"federated_client_credentials": "연동 클라이언트 자격 증명",
|
||||
"federated_client_credentials_description": "연동 클라이언트 자격 증명을 이용하여, OIDC 클라이언트를 제3자 인증 기관에서 발급한 JWT 토큰을 이용해 인증할 수 있습니다.",
|
||||
"federated_client_credentials_description": "연방 클라이언트 자격 증명은 장기 비밀을 관리하지 않고도 OIDC 클라이언트를 인증할 수 있게 합니다. 이는 클라이언트 어설션(예: 워크로드 신원 토큰)을 위해 제3자 기관이 발급한 JWT 토큰을 활용합니다.",
|
||||
"add_federated_client_credential": "연동 클라이언트 자격 증명 추가",
|
||||
"add_another_federated_client_credential": "다른 연동 클라이언트 자격 증명 추가",
|
||||
"oidc_allowed_group_count": "허용된 그룹 수",
|
||||
@@ -490,7 +492,7 @@
|
||||
"scim_provisioning_description": "SCIM 프로비저닝을 통해 OIDC 클라이언트에서 사용자 및 그룹을 자동으로 프로비저닝 및 디프로비저닝할 수 있습니다. 자세한 내용은 <link href='https://pocket-id.org/docs/configuration/scim'>문서를</link> 참조하세요.",
|
||||
"scim_endpoint": "SCIM 엔드포인트",
|
||||
"scim_token": "SCIM 토큰",
|
||||
"last_successful_sync_at": "마지막 성공적인 동기화: {time}",
|
||||
"last_successful_sync_at": "마지막 동기화 성공: {time}",
|
||||
"scim_configuration_updated_successfully": "SCIM 구성이 성공적으로 업데이트되었습니다.",
|
||||
"scim_enabled_successfully": "SCIM이 성공적으로 활성화되었습니다.",
|
||||
"scim_disabled_successfully": "SCIM이 성공적으로 비활성화되었습니다.",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "검증됨으로 표시",
|
||||
"email_verification_sent": "확인 이메일이 성공적으로 발송되었습니다.",
|
||||
"emails_verified_by_default": "이메일은 기본적으로 확인됨",
|
||||
"emails_verified_by_default_description": "이 기능이 활성화되면, 사용자의 이메일 주소는 가입 시 또는 이메일 주소 변경 시 기본적으로 확인된 상태로 표시됩니다."
|
||||
"emails_verified_by_default_description": "이 기능이 활성화되면, 사용자의 이메일 주소는 가입 시 또는 이메일 주소 변경 시 기본적으로 확인된 상태로 표시됩니다.",
|
||||
"user_has_no_passkeys_yet": "이 사용자는 아직 패스키가 없습니다."
|
||||
}
|
||||
|
||||
528
frontend/messages/lv.json
Normal file
528
frontend/messages/lv.json
Normal file
@@ -0,0 +1,528 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"my_account": "Mans konts",
|
||||
"logout": "Izrakstīties",
|
||||
"confirm": "Apstiprināt",
|
||||
"docs": "Dokumentācija",
|
||||
"key": "Atslēga",
|
||||
"value": "Vērtība",
|
||||
"remove_custom_claim": "Noņemt pielāgoto claim",
|
||||
"add_custom_claim": "Pievienot pielāgoto claim",
|
||||
"add_another": "Pievienot vēl vienu",
|
||||
"select_a_date": "Izvēlieties datumu",
|
||||
"select_file": "Izvēlieties failu",
|
||||
"profile_picture": "Profila attēls",
|
||||
"profile_picture_is_managed_by_ldap_server": "Profila attēlu pārvalda LDAP serveris, un to šeit nevar mainīt.",
|
||||
"click_profile_picture_to_upload_custom": "Noklikšķiniet uz profila attēla, lai augšupielādētu savu attēlu no failiem.",
|
||||
"image_should_be_in_format": "Attēlam jābūt PNG, JPEG vai WEBP formātā.",
|
||||
"items_per_page": "Vienumi lapā",
|
||||
"no_items_found": "Vienumi nav atrasti",
|
||||
"select_items": "Izvēlieties vienumus...",
|
||||
"search": "Meklēt...",
|
||||
"expand_card": "Izvērst kartīti",
|
||||
"copied": "Nokopēts",
|
||||
"click_to_copy": "Noklikšķiniet, lai kopētu",
|
||||
"something_went_wrong": "Kaut kas nogāja greizi",
|
||||
"go_back_to_home": "Atgriezties uz sākumlapu",
|
||||
"alternative_sign_in_methods": "Alternatīvas pierakstīšanās metodes",
|
||||
"login_background": "Pieteikšanās fons",
|
||||
"logo": "Logotips",
|
||||
"login_code": "Pieteikšanās kods",
|
||||
"create_a_login_code_to_sign_in_without_a_passkey_once": "Izveidojiet pieteikšanās kodu, ko lietotājs var vienreiz izmantot, lai pierakstītos bez piekļuves atslēgas.",
|
||||
"one_hour": "1 stunda",
|
||||
"twelve_hours": "12 stundas",
|
||||
"one_day": "1 diena",
|
||||
"one_week": "1 nedēļa",
|
||||
"one_month": "1 mēnesis",
|
||||
"expiration": "Derīguma termiņš",
|
||||
"generate_code": "Ģenerēt kodu",
|
||||
"name": "Nosaukums",
|
||||
"browser_unsupported": "Pārlūks netiek atbalstīts",
|
||||
"this_browser_does_not_support_passkeys": "Šis pārlūks neatbalsta piekļuves atslēgas. Lūdzu, izmantojiet alternatīvu pierakstīšanās metodi.",
|
||||
"an_unknown_error_occurred": "Radās nezināma kļūda",
|
||||
"authentication_process_was_aborted": "Autentifikācijas process tika pārtraukts",
|
||||
"error_occurred_with_authenticator": "Radās kļūda ar autentifikatoru",
|
||||
"authenticator_does_not_support_discoverable_credentials": "Autentifikators neatbalsta atklājamus akreditācijas datus",
|
||||
"authenticator_does_not_support_resident_keys": "Autentifikators neatbalsta rezidentās atslēgas",
|
||||
"passkey_was_previously_registered": "Šī piekļuves atslēga jau iepriekš tika reģistrēta",
|
||||
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentifikators neatbalsta nevienu no pieprasītajiem algoritmiem",
|
||||
"webauthn_error_invalid_rp_id": "Norādītais relying party ID nav derīgs.",
|
||||
"webauthn_error_invalid_domain": "Norādītais domēns nav derīgs.",
|
||||
"contact_administrator_to_fix": "Sazinieties ar administratoru, lai novērstu šo problēmu.",
|
||||
"webauthn_operation_not_allowed_or_timed_out": "Darbība nebija atļauta vai arī iestājās noildze",
|
||||
"webauthn_not_supported_by_browser": "Šis pārlūks neatbalsta piekļuves atslēgas. Lūdzu, izmantojiet alternatīvu pierakstīšanās metodi.",
|
||||
"critical_error_occurred_contact_administrator": "Radās kritiska kļūda. Lūdzu, sazinieties ar administratoru.",
|
||||
"sign_in_to": "Pierakstīties {name}",
|
||||
"client_not_found": "Klients nav atrasts",
|
||||
"client_wants_to_access_the_following_information": "<b>{client}</b> vēlas piekļūt šādai informācijai:",
|
||||
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Vai vēlaties pierakstīties <b>{client}</b> ar savu {appName} kontu?",
|
||||
"email": "E-pasts",
|
||||
"view_your_email_address": "Skatīt savu e-pasta adresi",
|
||||
"profile": "Profils",
|
||||
"view_your_profile_information": "Skatīt savu profila informāciju",
|
||||
"groups": "Grupas",
|
||||
"view_the_groups_you_are_a_member_of": "Skatīt grupas, kuru dalībnieks esat",
|
||||
"cancel": "Atcelt",
|
||||
"sign_in": "Pierakstīties",
|
||||
"try_again": "Mēģināt vēlreiz",
|
||||
"client_logo": "Klienta logotips",
|
||||
"sign_out": "Izrakstīties",
|
||||
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Vai vēlaties izrakstīties no {appName} ar kontu <b>{username}</b>?",
|
||||
"sign_in_to_appname": "Pierakstīties {appName}",
|
||||
"please_try_to_sign_in_again": "Lūdzu, mēģiniet pierakstīties vēlreiz.",
|
||||
"authenticate_with_passkey_to_access_account": "Lai piekļūtu savam kontam, apstipriniet identitāti ar piekļuves atslēgu.",
|
||||
"authenticate": "Autentificēties",
|
||||
"please_try_again": "Lūdzu, mēģiniet vēlreiz.",
|
||||
"continue": "Turpināt",
|
||||
"alternative_sign_in": "Alternatīva pierakstīšanās",
|
||||
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Ja jums nav piekļuves savai piekļuves atslēgai, varat pierakstīties, izmantojot kādu no šīm metodēm.",
|
||||
"use_your_passkey_instead": "Tā vietā izmantot savu piekļuves atslēgu?",
|
||||
"email_login": "Pierakstīšanās ar e-pastu",
|
||||
"enter_a_login_code_to_sign_in": "Ievadiet pieteikšanās kodu, lai pierakstītos.",
|
||||
"sign_in_with_login_code": "Pierakstīties ar pieteikšanās kodu",
|
||||
"request_a_login_code_via_email": "Pieprasīt pieteikšanās kodu pa e-pastu.",
|
||||
"go_back": "Atpakaļ",
|
||||
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Ja norādītais e-pasts pastāv sistēmā, uz to ir nosūtīts e-pasts.",
|
||||
"enter_code": "Ievadiet kodu",
|
||||
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Ievadiet savu e-pasta adresi, lai saņemtu e-pastu ar pieteikšanās kodu.",
|
||||
"your_email": "Jūsu e-pasts",
|
||||
"submit": "Iesniegt",
|
||||
"enter_the_code_you_received_to_sign_in": "Ievadiet saņemto kodu, lai pierakstītos.",
|
||||
"code": "Kods",
|
||||
"invalid_redirect_url": "Nederīgs novirzīšanas URL",
|
||||
"audit_log": "Audita žurnāls",
|
||||
"users": "Lietotāji",
|
||||
"user_groups": "Lietotāju grupas",
|
||||
"oidc_clients": "OIDC klienti",
|
||||
"api_keys": "API atslēgas",
|
||||
"application_configuration": "Lietotnes konfigurācija",
|
||||
"settings": "Iestatījumi",
|
||||
"update_pocket_id": "Atjaunināt Pocket ID",
|
||||
"powered_by": "Darbojas ar",
|
||||
"see_your_recent_account_activities": "Skatiet sava konta aktivitātes konfigurētajā saglabāšanas periodā.",
|
||||
"time": "Laiks",
|
||||
"event": "Notikums",
|
||||
"approximate_location": "Aptuvenā atrašanās vieta",
|
||||
"ip_address": "IP adrese",
|
||||
"device": "Ierīce",
|
||||
"client": "Klients",
|
||||
"actor": "Aktieris",
|
||||
"unknown": "Nezināms",
|
||||
"account_details_updated_successfully": "Konta dati veiksmīgi atjaunināti",
|
||||
"profile_picture_updated_successfully": "Profila attēls veiksmīgi atjaunināts. Izmaiņu parādīšanās var aizņemt dažas minūtes.",
|
||||
"account_settings": "Konta iestatījumi",
|
||||
"passkey_missing": "Trūkst piekļuves atslēgas",
|
||||
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Lūdzu, pievienojiet piekļuves atslēgu, lai nezaudētu piekļuvi savam kontam.",
|
||||
"single_passkey_configured": "Ir konfigurēta viena piekļuves atslēga",
|
||||
"it_is_recommended_to_add_more_than_one_passkey": "Ieteicams pievienot vairāk nekā vienu piekļuves atslēgu, lai nezaudētu piekļuvi kontam.",
|
||||
"account_details": "Konta dati",
|
||||
"passkeys": "Piekļuves atslēgas",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Pārvaldiet savas piekļuves atslēgas, kuras varat izmantot autentifikācijai.",
|
||||
"manage_this_users_passkeys": "Pārvaldīt šī lietotāja piekļuves atslēgas.",
|
||||
"add_passkey": "Pievienot piekļuves atslēgu",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Izveidojiet vienreizēju pieteikšanās kodu, lai pierakstītos no citas ierīces bez piekļuves atslēgas.",
|
||||
"create": "Izveidot",
|
||||
"first_name": "Vārds",
|
||||
"last_name": "Uzvārds",
|
||||
"username": "Lietotājvārds",
|
||||
"save": "Saglabāt",
|
||||
"username_can_only_contain": "Lietotājvārds drīkst saturēt tikai mazos burtus, ciparus, pasvītras, punktus, defises un '@' simbolus",
|
||||
"username_must_start_with": "Lietotājvārdam jāsākas ar burtciparu rakstzīmi",
|
||||
"username_must_end_with": "Lietotājvārdam jābeidzas ar burtciparu rakstzīmi",
|
||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Pierakstieties, izmantojot šo kodu. Koda derīguma termiņš beigsies pēc 15 minūtēm.",
|
||||
"or_visit": "vai apmeklējiet",
|
||||
"added_on": "Pievienots",
|
||||
"rename": "Pārdēvēt",
|
||||
"delete": "Dzēst",
|
||||
"are_you_sure_you_want_to_delete_this_passkey": "Vai tiešām vēlaties dzēst šo piekļuves atslēgu?",
|
||||
"passkey_deleted_successfully": "Piekļuves atslēga veiksmīgi dzēsta",
|
||||
"delete_passkey_name": "Dzēst {passkeyName}",
|
||||
"passkey_name_updated_successfully": "Piekļuves atslēgas nosaukums veiksmīgi atjaunināts",
|
||||
"name_passkey": "Nosauciet piekļuves atslēgu",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Piešķiriet piekļuves atslēgai nosaukumu, lai vēlāk to būtu viegli atpazīt.",
|
||||
"create_api_key": "Izveidot API atslēgu",
|
||||
"add_a_new_api_key_for_programmatic_access": "Pievienojiet jaunu API atslēgu programmātiskai piekļuvei <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Pievienot API atslēgu",
|
||||
"manage_api_keys": "Pārvaldīt API atslēgas",
|
||||
"api_key_created": "API atslēga izveidota",
|
||||
"for_security_reasons_this_key_will_only_be_shown_once": "Drošības apsvērumu dēļ šī atslēga tiks parādīta tikai vienu reizi. Lūdzu, glabājiet to drošā vietā.",
|
||||
"description": "Apraksts",
|
||||
"api_key": "API atslēga",
|
||||
"close": "Aizvērt",
|
||||
"name_to_identify_this_api_key": "Nosaukums šīs API atslēgas atpazīšanai.",
|
||||
"expires_at": "Beidzas",
|
||||
"when_this_api_key_will_expire": "Kad šīs API atslēgas derīguma termiņš beigsies.",
|
||||
"optional_description_to_help_identify_this_keys_purpose": "Neobligāts apraksts, lai palīdzētu saprast šīs atslēgas mērķi.",
|
||||
"expiration_date_must_be_in_the_future": "Derīguma termiņa datumam jābūt nākotnē",
|
||||
"revoke_api_key": "Atsaukt API atslēgu",
|
||||
"never": "Nekad",
|
||||
"revoke": "Atsaukt",
|
||||
"api_key_revoked_successfully": "API atslēga veiksmīgi atsaukta",
|
||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Vai tiešām vēlaties atsaukt API atslēgu \"{apiKeyName}\"? Tas pārtrauks visas integrācijas, kas izmanto šo atslēgu.",
|
||||
"last_used": "Pēdējoreiz izmantota",
|
||||
"actions": "Darbības",
|
||||
"images_updated_successfully": "Attēli veiksmīgi atjaunināti. Izmaiņu parādīšanās var aizņemt dažas minūtes.",
|
||||
"general": "Vispārīgi",
|
||||
"configure_smtp_to_send_emails": "Iespējojiet e-pasta paziņojumus, lai brīdinātu lietotājus, kad tiek konstatēta pieteikšanās no jaunas ierīces vai atrašanās vietas.",
|
||||
"ldap": "LDAP",
|
||||
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Konfigurējiet LDAP iestatījumus, lai sinhronizētu lietotājus un grupas no LDAP servera.",
|
||||
"images": "Attēli",
|
||||
"update": "Atjaunināt",
|
||||
"email_configuration_updated_successfully": "E-pasta konfigurācija veiksmīgi atjaunināta",
|
||||
"save_changes_question": "Saglabāt izmaiņas?",
|
||||
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "Pirms testa e-pasta nosūtīšanas ir jāsaglabā izmaiņas. Vai vēlaties saglabāt tagad?",
|
||||
"save_and_send": "Saglabāt un nosūtīt",
|
||||
"test_email_sent_successfully": "Testa e-pasts veiksmīgi nosūtīts uz jūsu e-pasta adresi.",
|
||||
"failed_to_send_test_email": "Neizdevās nosūtīt testa e-pastu. Plašāku informāciju skatiet servera žurnālos.",
|
||||
"smtp_configuration": "SMTP konfigurācija",
|
||||
"smtp_host": "SMTP resursdators",
|
||||
"smtp_port": "SMTP ports",
|
||||
"smtp_user": "SMTP lietotājs",
|
||||
"smtp_password": "SMTP parole",
|
||||
"smtp_from": "SMTP sūtītājs",
|
||||
"smtp_tls_option": "SMTP TLS opcija",
|
||||
"email_tls_option": "E-pasta TLS opcija",
|
||||
"skip_certificate_verification": "Izlaist sertifikāta pārbaudi",
|
||||
"this_can_be_useful_for_selfsigned_certificates": "Tas var būt noderīgi pašparakstītiem sertifikātiem.",
|
||||
"enabled_emails": "Iespējotie e-pasti",
|
||||
"email_login_notification": "Paziņojums par pieteikšanos pa e-pastu",
|
||||
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Nosūtīt e-pastu lietotājam, kad viņš piesakās no jaunas ierīces.",
|
||||
"emai_login_code_requested_by_user": "Lietotāja pieprasīts e-pasta pieteikšanās kods",
|
||||
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Atļauj lietotājiem apiet piekļuves atslēgas, pieprasot pieteikšanās kodu uz savu e-pastu. Tas būtiski samazina drošību, jo ikviens, kam ir piekļuve lietotāja e-pastam, var iegūt piekļuvi.",
|
||||
"email_login_code_from_admin": "Administratora e-pasta pieteikšanās kods",
|
||||
"allows_an_admin_to_send_a_login_code_to_the_user": "Ļauj administratoram nosūtīt lietotājam pieteikšanās kodu pa e-pastu.",
|
||||
"send_test_email": "Nosūtīt testa e-pastu",
|
||||
"application_configuration_updated_successfully": "Lietotnes konfigurācija veiksmīgi atjaunināta",
|
||||
"application_name": "Lietotnes nosaukums",
|
||||
"session_duration": "Sesijas ilgums",
|
||||
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Sesijas ilgums minūtēs, pēc kura lietotājam jāpierakstās atkārtoti.",
|
||||
"enable_self_account_editing": "Atļaut lietotājam rediģēt savu kontu",
|
||||
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Vai lietotājiem jāspēj rediģēt sava konta datus.",
|
||||
"ldap_configuration_updated_successfully": "LDAP konfigurācija veiksmīgi atjaunināta",
|
||||
"ldap_disabled_successfully": "LDAP veiksmīgi atspējots",
|
||||
"ldap_sync_finished": "LDAP sinhronizācija pabeigta",
|
||||
"client_configuration": "Klienta konfigurācija",
|
||||
"ldap_url": "LDAP URL",
|
||||
"ldap_bind_dn": "LDAP Bind DN",
|
||||
"ldap_bind_password": "LDAP Bind parole",
|
||||
"ldap_base_dn": "LDAP Base DN",
|
||||
"user_search_filter": "Lietotāju meklēšanas filtrs",
|
||||
"the_search_filter_to_use_to_search_or_sync_users": "Meklēšanas filtrs, ko izmantot lietotāju meklēšanai/sinhronizēšanai.",
|
||||
"groups_search_filter": "Grupu meklēšanas filtrs",
|
||||
"the_search_filter_to_use_to_search_or_sync_groups": "Meklēšanas filtrs, ko izmantot grupu meklēšanai/sinhronizēšanai.",
|
||||
"attribute_mapping": "Atribūtu kartēšana",
|
||||
"user_unique_identifier_attribute": "Lietotāja unikālā identifikatora atribūts",
|
||||
"the_value_of_this_attribute_should_never_change": "Šī atribūta vērtībai nekad nevajadzētu mainīties.",
|
||||
"username_attribute": "Lietotājvārda atribūts",
|
||||
"user_mail_attribute": "Lietotāja e-pasta atribūts",
|
||||
"user_first_name_attribute": "Lietotāja vārda atribūts",
|
||||
"user_last_name_attribute": "Lietotāja uzvārda atribūts",
|
||||
"user_profile_picture_attribute": "Lietotāja profila attēla atribūts",
|
||||
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "Šī atribūta vērtība var būt URL, bināri dati vai base64 kodēts attēls.",
|
||||
"group_members_attribute": "Grupas dalībnieku atribūts",
|
||||
"the_attribute_to_use_for_querying_members_of_a_group": "Atribūts, ko izmantot grupas dalībnieku vaicājumiem.",
|
||||
"group_unique_identifier_attribute": "Grupas unikālā identifikatora atribūts",
|
||||
"group_rdn_attribute": "Grupas RDN atribūts (DN ietvaros)",
|
||||
"admin_group_name": "Administratoru grupas nosaukums",
|
||||
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Šīs grupas dalībniekiem būs administratora tiesības Pocket ID.",
|
||||
"disable": "Atspējot",
|
||||
"sync_now": "Sinhronizēt tagad",
|
||||
"enable": "Iespējot",
|
||||
"user_created_successfully": "Lietotājs veiksmīgi izveidots",
|
||||
"create_user": "Izveidot lietotāju",
|
||||
"add_a_new_user_to_appname": "Pievienot jaunu lietotāju {appName}",
|
||||
"add_user": "Pievienot lietotāju",
|
||||
"manage_users": "Pārvaldīt lietotājus",
|
||||
"admin_privileges": "Administratora tiesības",
|
||||
"admins_have_full_access_to_the_admin_panel": "Administratoriem ir pilna piekļuve administrācijas panelim.",
|
||||
"delete_firstname_lastname": "Dzēst {firstName} {lastName}",
|
||||
"are_you_sure_you_want_to_delete_this_user": "Vai tiešām vēlaties dzēst šo lietotāju?",
|
||||
"user_deleted_successfully": "Lietotājs veiksmīgi dzēsts",
|
||||
"role": "Loma",
|
||||
"source": "Avots",
|
||||
"admin": "Administrators",
|
||||
"user": "Lietotājs",
|
||||
"local": "Lokāls",
|
||||
"toggle_menu": "Pārslēgt izvēlni",
|
||||
"edit": "Rediģēt",
|
||||
"user_groups_updated_successfully": "Lietotāju grupas veiksmīgi atjauninātas",
|
||||
"user_updated_successfully": "Lietotājs veiksmīgi atjaunināts",
|
||||
"custom_claims_updated_successfully": "Pielāgotie claim veiksmīgi atjaunināti",
|
||||
"back": "Atpakaļ",
|
||||
"user_details_firstname_lastname": "Lietotāja dati {firstName} {lastName}",
|
||||
"manage_which_groups_this_user_belongs_to": "Pārvaldiet, kurām grupām šis lietotājs pieder.",
|
||||
"custom_claims": "Pielāgotie claim",
|
||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Pielāgotie claim ir atslēgas-vērtības pāri, ko var izmantot papildu informācijas glabāšanai par lietotāju. Šie claim tiks iekļauti ID tokenā, ja ir pieprasīts scope 'profile'.",
|
||||
"user_group_created_successfully": "Lietotāju grupa veiksmīgi izveidota",
|
||||
"create_user_group": "Izveidot lietotāju grupu",
|
||||
"create_a_new_group_that_can_be_assigned_to_users": "Izveidot jaunu grupu, ko var piešķirt lietotājiem.",
|
||||
"add_group": "Pievienot grupu",
|
||||
"manage_user_groups": "Pārvaldīt lietotāju grupas",
|
||||
"friendly_name": "Draudzīgais nosaukums",
|
||||
"name_that_will_be_displayed_in_the_ui": "Nosaukums, kas tiks parādīts lietotāja saskarnē",
|
||||
"name_that_will_be_in_the_groups_claim": "Nosaukums, kas būs \"groups\" claim",
|
||||
"delete_name": "Dzēst {name}",
|
||||
"are_you_sure_you_want_to_delete_this_user_group": "Vai tiešām vēlaties dzēst šo lietotāju grupu?",
|
||||
"user_group_deleted_successfully": "Lietotāju grupa veiksmīgi dzēsta",
|
||||
"user_count": "Lietotāju skaits",
|
||||
"user_group_updated_successfully": "Lietotāju grupa veiksmīgi atjaunināta",
|
||||
"users_updated_successfully": "Lietotāji veiksmīgi atjaunināti",
|
||||
"user_group_details_name": "Lietotāju grupas dati {name}",
|
||||
"assign_users_to_this_group": "Piešķiriet lietotājus šai grupai.",
|
||||
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Pielāgotie claim ir atslēgas-vērtības pāri, ko var izmantot papildu informācijas glabāšanai par lietotāju. Šie claim tiks iekļauti ID tokenā, ja ir pieprasīts scope 'profile'. Ja ir konflikti, prioritāte būs lietotāja līmenī definētajiem claim.",
|
||||
"oidc_client_created_successfully": "OIDC klients veiksmīgi izveidots",
|
||||
"create_oidc_client": "Izveidot OIDC klientu",
|
||||
"add_a_new_oidc_client_to_appname": "Pievienot jaunu OIDC klientu {appName}.",
|
||||
"add_oidc_client": "Pievienot OIDC klientu",
|
||||
"manage_oidc_clients": "Pārvaldīt OIDC klientus",
|
||||
"one_time_link": "Vienreizēja saite",
|
||||
"use_this_link_to_sign_in_once": "Izmantojiet šo saiti, lai pierakstītos vienu reizi. Tas ir vajadzīgs lietotājiem, kuri vēl nav pievienojuši piekļuves atslēgu vai ir to pazaudējuši.",
|
||||
"add": "Pievienot",
|
||||
"callback_urls": "Callback URL",
|
||||
"logout_callback_urls": "Logout callback URL",
|
||||
"public_client": "Publisks klients",
|
||||
"public_clients_description": "Publiskajiem klientiem nav client secret. Tie ir paredzēti mobilajām, tīmekļa un native lietotnēm, kur noslēpumus nevar droši glabāt.",
|
||||
"pkce": "PKCE",
|
||||
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange ir drošības funkcija, kas palīdz novērst CSRF un autorizācijas koda pārtveršanas uzbrukumus.",
|
||||
"requires_reauthentication": "Nepieciešama atkārtota autentifikācija",
|
||||
"requires_users_to_authenticate_again_on_each_authorization": "Pieprasa lietotājiem autentificēties atkārtoti katrā autorizācijas reizē, pat ja viņi jau ir pierakstījušies",
|
||||
"name_logo": "{name} logotips",
|
||||
"change_logo": "Mainīt logotipu",
|
||||
"upload_logo": "Augšupielādēt logotipu",
|
||||
"remove_logo": "Noņemt logotipu",
|
||||
"are_you_sure_you_want_to_delete_this_oidc_client": "Vai tiešām vēlaties dzēst šo OIDC klientu?",
|
||||
"oidc_client_deleted_successfully": "OIDC klients veiksmīgi dzēsts",
|
||||
"authorization_url": "Autorizācijas URL",
|
||||
"oidc_discovery_url": "OIDC Discovery URL",
|
||||
"token_url": "Token URL",
|
||||
"userinfo_url": "Userinfo URL",
|
||||
"logout_url": "Logout URL",
|
||||
"certificate_url": "Sertifikāta URL",
|
||||
"enabled": "Iespējots",
|
||||
"disabled": "Atspējots",
|
||||
"oidc_client_updated_successfully": "OIDC klients veiksmīgi atjaunināts",
|
||||
"create_new_client_secret": "Izveidot jaunu client secret",
|
||||
"are_you_sure_you_want_to_create_a_new_client_secret": "Vai tiešām vēlaties izveidot jaunu client secret? Vecais tiks padarīts nederīgs.",
|
||||
"generate": "Ģenerēt",
|
||||
"new_client_secret_created_successfully": "Jaunais client secret veiksmīgi izveidots",
|
||||
"oidc_client_name": "OIDC klients {name}",
|
||||
"client_id": "Client ID",
|
||||
"client_secret": "Client secret",
|
||||
"show_more_details": "Rādīt vairāk informācijas",
|
||||
"allowed_user_groups": "Atļautās lietotāju grupas",
|
||||
"allowed_user_groups_description": "Izvēlieties lietotāju grupas, kuru dalībniekiem ir atļauts pierakstīties šajā klientā.",
|
||||
"allowed_user_groups_status_unrestricted_description": "Lietotāju grupu ierobežojumi netiek piemēroti. Šajā klientā var pierakstīties jebkurš lietotājs.",
|
||||
"unrestrict": "Noņemt ierobežojumus",
|
||||
"restrict": "Ierobežot",
|
||||
"user_groups_restriction_updated_successfully": "Lietotāju grupu ierobežojumi veiksmīgi atjaunināti",
|
||||
"allowed_user_groups_updated_successfully": "Atļautās lietotāju grupas veiksmīgi atjauninātas",
|
||||
"favicon": "Favicon",
|
||||
"light_mode_logo": "Gaišā režīma logotips",
|
||||
"dark_mode_logo": "Tumšā režīma logotips",
|
||||
"email_logo": "E-pasta logotips",
|
||||
"background_image": "Fona attēls",
|
||||
"language": "Valoda",
|
||||
"reset_profile_picture_question": "Atiestatīt profila attēlu?",
|
||||
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "Tas noņems augšupielādēto attēlu un atiestatīs profila attēlu uz noklusējuma. Vai vēlaties turpināt?",
|
||||
"reset": "Atiestatīt",
|
||||
"reset_to_default": "Atiestatīt uz noklusējumu",
|
||||
"profile_picture_has_been_reset": "Profila attēls ir atiestatīts. Izmaiņu parādīšanās var aizņemt dažas minūtes.",
|
||||
"select_the_language_you_want_to_use": "Izvēlieties valodu, kuru vēlaties izmantot. Lūdzu, ņemiet vērā, ka daļa teksta var būt automātiski tulkota un neprecīza.",
|
||||
"contribute_to_translation": "Ja pamanāt problēmu, varat palīdzēt uzlabot tulkojumu vietnē <link href='https://crowdin.com/project/pocket-id'>Crowdin</link>.",
|
||||
"personal": "Personīgs",
|
||||
"global": "Globāls",
|
||||
"all_users": "Visi lietotāji",
|
||||
"all_events": "Visi notikumi",
|
||||
"all_clients": "Visi klienti",
|
||||
"all_locations": "Visas atrašanās vietas",
|
||||
"global_audit_log": "Globālais audita žurnāls",
|
||||
"see_all_recent_account_activities": "Skatiet visu lietotāju kontu aktivitātes iestatītajā saglabāšanas periodā.",
|
||||
"token_sign_in": "Pierakstīšanās ar tokenu",
|
||||
"client_authorization": "Klienta autorizācija",
|
||||
"new_client_authorization": "Jauna klienta autorizācija",
|
||||
"device_code_authorization": "Ierīces koda autorizācija",
|
||||
"new_device_code_authorization": "Jauna ierīces koda autorizācija",
|
||||
"passkey_added": "Piekļuves atslēga pievienota",
|
||||
"passkey_removed": "Piekļuves atslēga noņemta",
|
||||
"disable_animations": "Atspējot animācijas",
|
||||
"turn_off_ui_animations": "Izslēgt animācijas visā lietotāja saskarnē.",
|
||||
"user_disabled": "Konts atspējots",
|
||||
"disabled_users_cannot_log_in_or_use_services": "Atspējoti lietotāji nevar pierakstīties vai izmantot pakalpojumus.",
|
||||
"user_disabled_successfully": "Lietotājs veiksmīgi atspējots.",
|
||||
"user_enabled_successfully": "Lietotājs veiksmīgi iespējots.",
|
||||
"status": "Statuss",
|
||||
"disable_firstname_lastname": "Atspējot {firstName} {lastName}",
|
||||
"are_you_sure_you_want_to_disable_this_user": "Vai tiešām vēlaties atspējot šo lietotāju? Viņš nevarēs pierakstīties vai piekļūt nevienam pakalpojumam.",
|
||||
"ldap_soft_delete_users": "Saglabāt no LDAP atspējotus lietotājus.",
|
||||
"ldap_soft_delete_users_description": "Ja iespējots, lietotāji, kas noņemti no LDAP, tiks atspējoti, nevis dzēsti no sistēmas.",
|
||||
"login_code_email_success": "Pieteikšanās kods ir nosūtīts lietotājam.",
|
||||
"send_email": "Sūtīt e-pastu",
|
||||
"show_code": "Rādīt kodu",
|
||||
"callback_url_description": "Jūsu klienta norādītais URL(s). Ja atstāsit tukšu, tas tiks pievienots automātiski. Tiek atbalstīti arī <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>wildcard</link> URL.",
|
||||
"logout_callback_url_description": "Jūsu klienta logout URL(s). Tiek atbalstīti arī <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>wildcard</link> URL.",
|
||||
"api_key_expiration": "API atslēgas derīguma termiņš",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Nosūtīt e-pastu lietotājam, kad viņa API atslēgai drīz beigsies derīguma termiņš.",
|
||||
"authorize_device": "Autorizēt ierīci",
|
||||
"the_device_has_been_authorized": "Ierīce ir autorizēta.",
|
||||
"enter_code_displayed_in_previous_step": "Ievadiet iepriekšējā solī parādīto kodu.",
|
||||
"authorize": "Autorizēt",
|
||||
"federated_client_credentials": "Federētie klienta akreditācijas dati",
|
||||
"federated_client_credentials_description": "Federētie klienta akreditācijas dati ļauj autentificēt OIDC klientus, nepārvaldot ilgtermiņa noslēpumus. Tie izmanto trešo pušu autoritāšu izsniegtus JWT tokenus client assertion vajadzībām, piemēram, workload identity tokenus.",
|
||||
"add_federated_client_credential": "Pievienot federētos klienta akreditācijas datus",
|
||||
"add_another_federated_client_credential": "Pievienot vēl vienus federētos klienta akreditācijas datus",
|
||||
"oidc_allowed_group_count": "Atļauto grupu skaits",
|
||||
"unrestricted": "Neierobežots",
|
||||
"show_advanced_options": "Rādīt papildu opcijas",
|
||||
"hide_advanced_options": "Paslēpt papildu opcijas",
|
||||
"oidc_data_preview": "OIDC datu priekšskatījums",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_different_users": "Priekšskatīt OIDC datus, kas tiktu nosūtīti dažādiem lietotājiem",
|
||||
"id_token": "ID tokens",
|
||||
"access_token": "Piekļuves tokens",
|
||||
"userinfo": "Userinfo",
|
||||
"id_token_payload": "ID tokena saturs",
|
||||
"access_token_payload": "Piekļuves tokena saturs",
|
||||
"userinfo_endpoint_response": "Userinfo galapunkta atbilde",
|
||||
"copy": "Kopēt",
|
||||
"no_preview_data_available": "Nav pieejamu priekšskatījuma datu",
|
||||
"copy_all": "Kopēt visu",
|
||||
"preview": "Priekšskatījums",
|
||||
"preview_for_user": "Priekšskatījums lietotājam {name}",
|
||||
"preview_the_oidc_data_that_would_be_sent_for_this_user": "Priekšskatīt OIDC datus, kas tiktu nosūtīti šim lietotājam",
|
||||
"show": "Rādīt",
|
||||
"select_an_option": "Izvēlieties opciju",
|
||||
"select_user": "Izvēlieties lietotāju",
|
||||
"error": "Kļūda",
|
||||
"select_an_accent_color_to_customize_the_appearance_of_pocket_id": "Izvēlieties akcenta krāsu, lai pielāgotu Pocket ID izskatu.",
|
||||
"accent_color": "Akcenta krāsa",
|
||||
"custom_accent_color": "Pielāgota akcenta krāsa",
|
||||
"custom_accent_color_description": "Ievadiet pielāgotu krāsu derīgos CSS krāsu formātos (piemēram, hex, rgb, hsl).",
|
||||
"color_value": "Krāsas vērtība",
|
||||
"apply": "Pielietot",
|
||||
"signup_token": "Reģistrācijas tokens",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Izveidojiet reģistrācijas tokenu, lai atļautu jaunu lietotāju reģistrāciju.",
|
||||
"usage_limit": "Lietošanas limits",
|
||||
"number_of_times_token_can_be_used": "Cik reižu tokenu var izmantot.",
|
||||
"expires": "Beidzas",
|
||||
"signup": "Reģistrēties",
|
||||
"user_creation": "Lietotāju izveide",
|
||||
"configure_user_creation": "Pārvaldiet lietotāju izveides iestatījumus, tostarp reģistrācijas metodes un noklusējuma tiesības jaunajiem lietotājiem.",
|
||||
"user_creation_groups_description": "Automātiski piešķirt šīs grupas jauniem lietotājiem pēc reģistrācijas.",
|
||||
"user_creation_claims_description": "Automātiski piešķirt šos pielāgotos claim jaunajiem lietotājiem pēc reģistrācijas.",
|
||||
"user_creation_updated_successfully": "Lietotāju izveides iestatījumi veiksmīgi atjaunināti.",
|
||||
"signup_disabled_description": "Lietotāju reģistrācija ir pilnībā atspējota. Jaunus kontus var izveidot tikai administratori.",
|
||||
"signup_requires_valid_token": "Konta izveidei ir nepieciešams derīgs reģistrācijas tokens",
|
||||
"validating_signup_token": "Notiek reģistrācijas tokena pārbaude",
|
||||
"go_to_login": "Doties uz pieteikšanos",
|
||||
"signup_to_appname": "Reģistrēties {appName}",
|
||||
"create_your_account_to_get_started": "Izveidojiet savu kontu, lai sāktu.",
|
||||
"initial_account_creation_description": "Lūdzu, izveidojiet savu kontu, lai sāktu. Piekļuves atslēgu varēsiet iestatīt vēlāk.",
|
||||
"setup_your_passkey": "Iestatiet savu piekļuves atslēgu",
|
||||
"create_a_passkey_to_securely_access_your_account": "Izveidojiet piekļuves atslēgu, lai droši piekļūtu savam kontam. Tas būs galvenais veids, kā pierakstīties.",
|
||||
"skip_for_now": "Pagaidām izlaist",
|
||||
"account_created": "Konts izveidots",
|
||||
"enable_user_signups": "Iespējot lietotāju reģistrāciju",
|
||||
"enable_user_signups_description": "Nosakiet, kā lietotāji var reģistrēt jaunus kontus Pocket ID.",
|
||||
"user_signups_are_disabled": "Lietotāju reģistrācija šobrīd ir atspējota",
|
||||
"create_signup_token": "Izveidot reģistrācijas tokenu",
|
||||
"view_active_signup_tokens": "Skatīt aktīvos reģistrācijas tokenus",
|
||||
"manage_signup_tokens": "Pārvaldīt reģistrācijas tokenus",
|
||||
"view_and_manage_active_signup_tokens": "Skatīt un pārvaldīt aktīvos reģistrācijas tokenus.",
|
||||
"signup_token_deleted_successfully": "Reģistrācijas tokens veiksmīgi dzēsts.",
|
||||
"expired": "Beidzies",
|
||||
"used_up": "Izlietots",
|
||||
"active": "Aktīvs",
|
||||
"usage": "Lietojums",
|
||||
"created": "Izveidots",
|
||||
"token": "Tokens",
|
||||
"loading": "Ielādē",
|
||||
"delete_signup_token": "Dzēst reģistrācijas tokenu",
|
||||
"are_you_sure_you_want_to_delete_this_signup_token": "Vai tiešām vēlaties dzēst šo reģistrācijas tokenu? Šo darbību nevar atsaukt.",
|
||||
"signup_with_token": "Reģistrēties ar tokenu",
|
||||
"signup_with_token_description": "Lietotāji var reģistrēties tikai ar derīgu reģistrācijas tokenu, ko izveidojis administrators.",
|
||||
"signup_open": "Atvērta reģistrācija",
|
||||
"signup_open_description": "Jebkurš var izveidot jaunu kontu bez ierobežojumiem.",
|
||||
"of": "no",
|
||||
"skip_passkey_setup": "Izlaist piekļuves atslēgas iestatīšanu",
|
||||
"skip_passkey_setup_description": "Ir ļoti ieteicams iestatīt piekļuves atslēgu, jo bez tās pēc sesijas beigām jūs zaudēsiet piekļuvi savam kontam.",
|
||||
"my_apps": "Manas lietotnes",
|
||||
"no_apps_available": "Nav pieejamu lietotņu",
|
||||
"contact_your_administrator_for_app_access": "Sazinieties ar administratoru, lai saņemtu piekļuvi lietotnēm.",
|
||||
"launch": "Atvērt",
|
||||
"client_launch_url": "Klienta palaišanas URL",
|
||||
"client_launch_url_description": "URL, kas tiks atvērts, kad lietotājs palaiž lietotni no lapas Manas lietotnes.",
|
||||
"client_name_description": "Klienta nosaukums, kas tiek rādīts Pocket ID saskarnē.",
|
||||
"revoke_access": "Atsaukt piekļuvi",
|
||||
"revoke_access_description": "Atsaukt piekļuvi <b>{clientName}</b>. <b>{clientName}</b> vairs nevarēs piekļūt jūsu konta informācijai.",
|
||||
"revoke_access_successful": "Piekļuve {clientName} veiksmīgi atsaukta.",
|
||||
"last_signed_in_ago": "Pēdējoreiz pierakstījās pirms {time}",
|
||||
"invalid_client_id": "Client ID drīkst saturēt tikai burtus, ciparus, pasvītras un defises",
|
||||
"custom_client_id_description": "Iestatiet pielāgotu client ID, ja tas ir nepieciešams jūsu lietotnei. Pretējā gadījumā atstājiet tukšu, lai ģenerētu nejaušu ID.",
|
||||
"generated": "Ģenerēts",
|
||||
"administration": "Administrēšana",
|
||||
"group_rdn_attribute_description": "Atribūts, ko izmanto grupas distinguished name (DN).",
|
||||
"display_name_attribute": "Parādāmā nosaukuma atribūts",
|
||||
"display_name": "Parādāmais nosaukums",
|
||||
"configure_application_images": "Konfigurēt lietotnes attēlus",
|
||||
"ui_config_disabled_info_title": "UI konfigurācija ir atspējota",
|
||||
"ui_config_disabled_info_description": "UI konfigurācija ir atspējota, jo lietotnes konfigurācijas iestatījumi tiek pārvaldīti ar vides mainīgajiem. Dažus iestatījumus var nebūt iespējams rediģēt.",
|
||||
"logo_from_url_description": "Ielīmējiet tiešu attēla URL (svg, png, webp). Ikonas var atrast vietnēs <link href=\"https://selfh.st/icons\">Selfh.st Icons</link> vai <link href=\"https://dashboardicons.com\">Dashboard Icons</link>.",
|
||||
"invalid_url": "Nederīgs URL",
|
||||
"require_user_email": "Pieprasīt e-pasta adresi",
|
||||
"require_user_email_description": "Pieprasa lietotājiem e-pasta adresi. Ja tas ir atspējots, lietotāji bez e-pasta adreses nevarēs izmantot funkcijas, kurām tā ir nepieciešama.",
|
||||
"view": "Skatīt",
|
||||
"toggle_columns": "Pārslēgt kolonnas",
|
||||
"locale": "Lokalizācija",
|
||||
"ldap_id": "LDAP ID",
|
||||
"reauthentication": "Atkārtota autentifikācija",
|
||||
"clear_filters": "Notīrīt filtrus",
|
||||
"default_profile_picture": "Noklusējuma profila attēls",
|
||||
"light": "Gaišs",
|
||||
"dark": "Tumšs",
|
||||
"system": "Sistēma",
|
||||
"signup_token_user_groups_description": "Automātiski piešķirt šīs grupas lietotājiem, kuri reģistrējas ar šo tokenu.",
|
||||
"allowed_oidc_clients": "Atļautie OIDC klienti",
|
||||
"allowed_oidc_clients_description": "Izvēlieties OIDC klientus, kuros šīs lietotāju grupas dalībniekiem ir atļauts pierakstīties.",
|
||||
"unrestrict_oidc_client": "Noņemt ierobežojumus klientam {clientName}",
|
||||
"confirm_unrestrict_oidc_client_description": "Vai tiešām vēlaties noņemt ierobežojumus OIDC klientam <b>{clientName}</b>? Tas noņems visas grupu piesaistes šim klientam, un jebkurš lietotājs varēs tajā pierakstīties.",
|
||||
"allowed_oidc_clients_updated_successfully": "Atļautie OIDC klienti veiksmīgi atjaunināti",
|
||||
"yes": "Jā",
|
||||
"no": "Nē",
|
||||
"restricted": "Ierobežots",
|
||||
"scim_provisioning": "SCIM provisioning",
|
||||
"scim_provisioning_description": "SCIM provisioning ļauj automātiski provisionēt un deprovisionēt lietotājus un grupas no jūsu OIDC klienta. Uzziniet vairāk <link href='https://pocket-id.org/docs/configuration/scim'>dokumentācijā</link>.",
|
||||
"scim_endpoint": "SCIM galapunkts",
|
||||
"scim_token": "SCIM tokens",
|
||||
"last_successful_sync_at": "Pēdējā veiksmīgā sinhronizācija: {time}",
|
||||
"scim_configuration_updated_successfully": "SCIM konfigurācija veiksmīgi atjaunināta.",
|
||||
"scim_enabled_successfully": "SCIM veiksmīgi iespējots.",
|
||||
"scim_disabled_successfully": "SCIM veiksmīgi atspējots.",
|
||||
"disable_scim_provisioning": "Atspējot SCIM provisioning",
|
||||
"disable_scim_provisioning_confirm_description": "Vai tiešām vēlaties atspējot SCIM provisioning klientam <b>{clientName}</b>? Tas apturēs visu automātisko lietotāju un grupu provisionēšanu un deprovisionēšanu.",
|
||||
"scim_sync_failed": "SCIM sinhronizācija neizdevās. Plašāku informāciju skatiet servera žurnālos.",
|
||||
"scim_sync_successful": "SCIM sinhronizācija veiksmīgi pabeigta.",
|
||||
"save_and_sync": "Saglabāt un sinhronizēt",
|
||||
"scim_save_changes_description": "Pirms SCIM sinhronizācijas sākšanas ir jāsaglabā izmaiņas. Vai vēlaties saglabāt tagad?",
|
||||
"scopes": "Scope",
|
||||
"issuer_url": "Issuer URL",
|
||||
"smtp_field_required_when_other_provided": "Obligāts, ja ir norādīts jebkurš cits SMTP iestatījums",
|
||||
"smtp_field_required_when_email_enabled": "Obligāts, ja ir iespējoti e-pasta paziņojumi",
|
||||
"renew": "Atjaunot",
|
||||
"renew_api_key": "Atjaunot API atslēgu",
|
||||
"renew_api_key_description": "Atjaunojot API atslēgu, tiks ģenerēta jauna atslēga. Neaizmirstiet atjaunināt visas integrācijas, kas izmanto šo atslēgu.",
|
||||
"api_key_renewed": "API atslēga atjaunota",
|
||||
"app_config_home_page": "Sākumlapa",
|
||||
"app_config_home_page_description": "Lapa, uz kuru lietotāji tiek novirzīti pēc pierakstīšanās.",
|
||||
"email_verification_warning": "Apstipriniet savu e-pasta adresi",
|
||||
"email_verification_warning_description": "Jūsu e-pasta adrese vēl nav apstiprināta. Lūdzu, apstipriniet to pēc iespējas ātrāk.",
|
||||
"email_verification": "E-pasta apstiprināšana",
|
||||
"email_verification_description": "Nosūtīt lietotājiem apstiprinājuma e-pastu, kad viņi reģistrējas vai maina savu e-pasta adresi.",
|
||||
"email_verification_success_title": "E-pasta adrese veiksmīgi apstiprināta",
|
||||
"email_verification_success_description": "Jūsu e-pasta adrese ir veiksmīgi apstiprināta.",
|
||||
"email_verification_error_title": "E-pasta apstiprināšana neizdevās",
|
||||
"mark_as_unverified": "Atzīmēt kā neapstiprinātu",
|
||||
"mark_as_verified": "Atzīmēt kā apstiprinātu",
|
||||
"email_verification_sent": "Apstiprinājuma e-pasts veiksmīgi nosūtīts.",
|
||||
"emails_verified_by_default": "E-pasti pēc noklusējuma ir apstiprināti",
|
||||
"emails_verified_by_default_description": "Ja iespējots, lietotāju e-pasta adreses pēc noklusējuma tiks atzīmētas kā apstiprinātas reģistrācijas laikā vai pēc e-pasta adreses maiņas.",
|
||||
"user_has_no_passkeys_yet": "Šim lietotājam vēl nav piekļuves atslēgu."
|
||||
}
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "IP-adres",
|
||||
"device": "Apparaat",
|
||||
"client": "Client",
|
||||
"actor": "Acteur",
|
||||
"unknown": "Onbekend",
|
||||
"account_details_updated_successfully": "Accountgegevens succesvol bijgewerkt",
|
||||
"profile_picture_updated_successfully": "Profielfoto succesvol bijgewerkt. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Accountgegevens",
|
||||
"passkeys": "Passkeys",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Beheer de passkeys waarmee je jezelf kunt verifiëren.",
|
||||
"manage_this_users_passkeys": "Beheer de toegangscodes van deze gebruiker.",
|
||||
"add_passkey": "Passkey toevoegen",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Maak een eenmalige inlogcode aan om in te loggen vanaf een ander apparaat zonder passkey.",
|
||||
"create": "Aanmaken",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Voer de code in die in de vorige stap werd getoond.",
|
||||
"authorize": "Autoriseren",
|
||||
"federated_client_credentials": "Federatieve clientreferenties",
|
||||
"federated_client_credentials_description": "Met federatieve clientreferenties kun je OIDC-clients verifiëren met JWT-tokens die zijn uitgegeven door andere instanties.",
|
||||
"federated_client_credentials_description": "Met federatieve klantgegevens kun je OIDC-klanten verifiëren zonder dat je langdurige geheimen hoeft te beheren. Ze gebruiken JWT-tokens die door externe instanties zijn uitgegeven voor klantverklaringen, zoals tokens voor werkbelastingidentiteit.",
|
||||
"add_federated_client_credential": "Federatieve clientreferenties toevoegen",
|
||||
"add_another_federated_client_credential": "Voeg nog een federatieve clientreferentie toe",
|
||||
"oidc_allowed_group_count": "Aantal groepen met toegang",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Markeer als geverifieerd",
|
||||
"email_verification_sent": "Verificatiemail is goed verstuurd.",
|
||||
"emails_verified_by_default": "E-mails standaard geverifieerd",
|
||||
"emails_verified_by_default_description": "Als je dit aan zet, worden de e-mailadressen van gebruikers standaard gemarkeerd als geverifieerd bij het aanmelden of als hun e-mailadres verandert."
|
||||
"emails_verified_by_default_description": "Als je dit aan zet, worden de e-mailadressen van gebruikers standaard gemarkeerd als geverifieerd bij het aanmelden of als hun e-mailadres verandert.",
|
||||
"user_has_no_passkeys_yet": "Deze gebruiker heeft nog geen toegangscodes."
|
||||
}
|
||||
|
||||
@@ -102,10 +102,11 @@
|
||||
"see_your_recent_account_activities": "See your account activities within the configured retention period.",
|
||||
"time": "Time",
|
||||
"event": "Event",
|
||||
"approximate_location": "Approximate Location",
|
||||
"approximate_location": "Omtrentlig plassering",
|
||||
"ip_address": "IP adresse",
|
||||
"device": "Enhet",
|
||||
"client": "Klient",
|
||||
"actor": "Actor",
|
||||
"unknown": "Ukjent",
|
||||
"account_details_updated_successfully": "Brukerdetaljer oppdatert",
|
||||
"profile_picture_updated_successfully": "Profilbildet er oppdatert. Det kan ta noen minutter før det vises overalt.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Kontodetaljer",
|
||||
"passkeys": "Passnøkler",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
|
||||
"manage_this_users_passkeys": "Manage this user's passkeys.",
|
||||
"add_passkey": "Legg til passnøkkel",
|
||||
"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": "Opprett",
|
||||
@@ -365,7 +367,7 @@
|
||||
"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.",
|
||||
"federated_client_credentials_description": "Federated client credentials allow authenticating OIDC clients without managing long-lived secrets. They leverage JWT tokens issued by third-party authorities for client assertions, e.g. workload identity tokens.",
|
||||
"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",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Mark as verified",
|
||||
"email_verification_sent": "Verification email sent successfully.",
|
||||
"emails_verified_by_default": "Emails verified by default",
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed."
|
||||
"emails_verified_by_default_description": "When enabled, users' email addresses will be marked as verified by default upon signup or when their email address is changed.",
|
||||
"user_has_no_passkeys_yet": "This user has no passkeys yet."
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@
|
||||
"ip_address": "Adres IP",
|
||||
"device": "Urządzenie",
|
||||
"client": "Klient",
|
||||
"actor": "Aktor",
|
||||
"unknown": "Nieznany",
|
||||
"account_details_updated_successfully": "Sukces! Szczegóły konta zostały zaktualizowane.",
|
||||
"profile_picture_updated_successfully": "Sukces! Zdjęcie profilowe zostało zaktualizowane. Może to potrwać kilka minut.",
|
||||
@@ -117,6 +118,7 @@
|
||||
"account_details": "Szczegóły konta",
|
||||
"passkeys": "Klucze",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Zarządzaj swoimi kluczami, których możesz użyć do uwierzytelnienia siebie.",
|
||||
"manage_this_users_passkeys": "Zarządzaj kluczami dostępu tego użytkownika.",
|
||||
"add_passkey": "Dodaj klucz",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Utwórz jednorazowy kod logowania, aby zalogować się z innego urządzenia bez klucza.",
|
||||
"create": "Utwórz",
|
||||
@@ -365,7 +367,7 @@
|
||||
"enter_code_displayed_in_previous_step": "Wprowadź kod wyświetlony w poprzednim kroku.",
|
||||
"authorize": "Autoryzuj",
|
||||
"federated_client_credentials": "Połączone poświadczenia klienta",
|
||||
"federated_client_credentials_description": "Korzystając z połączonych poświadczeń klienta, możecie uwierzytelnić klientów OIDC za pomocą tokenów JWT wydanych przez zewnętrzne organy.",
|
||||
"federated_client_credentials_description": "Połączone poświadczenia klienta umożliwiają uwierzytelnianie klientów OIDC bez konieczności zarządzania długotrwałymi sekretami. Wykorzystują one tokeny JWT wydane przez zewnętrzne organy do potwierdzania tożsamości klientów, np. tokeny tożsamości obciążenia.",
|
||||
"add_federated_client_credential": "Dodaj poświadczenia klienta federacyjnego",
|
||||
"add_another_federated_client_credential": "Dodaj kolejne poświadczenia klienta federacyjnego",
|
||||
"oidc_allowed_group_count": "Dopuszczalna liczba grup",
|
||||
@@ -521,5 +523,6 @@
|
||||
"mark_as_verified": "Oznacz jako zweryfikowane",
|
||||
"email_verification_sent": "Wiadomość e-mail z linkiem weryfikacyjnym została wysłana.",
|
||||
"emails_verified_by_default": "E-maile weryfikowane domyślnie",
|
||||
"emails_verified_by_default_description": "Po włączeniu tej opcji adresy e-mail użytkowników będą domyślnie oznaczane jako zweryfikowane podczas rejestracji lub zmiany adresu e-mail."
|
||||
"emails_verified_by_default_description": "Po włączeniu tej opcji adresy e-mail użytkowników będą domyślnie oznaczane jako zweryfikowane podczas rejestracji lub zmiany adresu e-mail.",
|
||||
"user_has_no_passkeys_yet": "Ten użytkownik nie ma jeszcze żadnych kluczy dostępu."
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user