mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-28 10:16:37 +00:00
Compare commits
76 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
375f0a0c34 | ||
|
|
522a4eee00 | ||
|
|
0c41872cd4 | ||
|
|
b3fe143136 | ||
|
|
a90c8abe51 | ||
|
|
ae269371da | ||
|
|
27caaf2cac | ||
|
|
0678699d0c | ||
|
|
4f82957e13 | ||
|
|
5e2534bd6b | ||
|
|
eb0456a395 | ||
|
|
f0249377ac | ||
|
|
97f2e4eec2 | ||
|
|
adbdfcf9ff | ||
|
|
94a48977ba | ||
|
|
5ab0996475 | ||
|
|
60825c5743 | ||
|
|
310b81c277 | ||
|
|
549b487663 | ||
|
|
6eebecd85a | ||
|
|
1de231f1ff | ||
|
|
aab7e364e8 | ||
|
|
56afebc242 | ||
|
|
bb7b0d5608 | ||
|
|
80558c5625 | ||
|
|
a5629e63d2 | ||
|
|
317879bb37 | ||
|
|
c62533d388 | ||
|
|
0978a89fcc | ||
|
|
53ef61a3e5 | ||
|
|
4811625cdd | ||
|
|
9dbc02e568 | ||
|
|
43a1e4a25b | ||
|
|
e78b16d0c6 | ||
|
|
1967de6828 |
@@ -2,7 +2,9 @@
|
||||
"name": "pocket-id",
|
||||
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": {}
|
||||
"ghcr.io/devcontainers/features/go:1": {
|
||||
"version": "1.26"
|
||||
}
|
||||
},
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
|
||||
4
.github/workflows/backend-linter.yml
vendored
4
.github/workflows/backend-linter.yml
vendored
@@ -32,9 +32,9 @@ jobs:
|
||||
go-version-file: backend/go.mod
|
||||
|
||||
- name: Run Golangci-lint
|
||||
uses: golangci/golangci-lint-action@v8.0.0
|
||||
uses: golangci/golangci-lint-action@v9.0.0
|
||||
with:
|
||||
version: v2.4.0
|
||||
version: v2.9.0
|
||||
args: --build-tags=exclude_frontend
|
||||
working-directory: backend
|
||||
only-new-issues: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
2
.github/workflows/build-next.yml
vendored
2
.github/workflows/build-next.yml
vendored
@@ -27,7 +27,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v6
|
||||
|
||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Cache Playwright Browsers
|
||||
uses: actions/cache@v4
|
||||
|
||||
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
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: "backend/go.mod"
|
||||
|
||||
2
.github/workflows/svelte-check.yml
vendored
2
.github/workflows/svelte-check.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm --filter pocket-id-frontend install --frozen-lockfile
|
||||
|
||||
104
CHANGELOG.md
104
CHANGELOG.md
@@ -1,3 +1,107 @@
|
||||
## 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
|
||||
|
||||
- ENCRYPTION_KEY needed for version and help commands ([#1256](https://github.com/pocket-id/pocket-id/pull/1256) by @kmendell)
|
||||
- prevent deletion of OIDC provider logo for non admin/anonymous users ([#1267](https://github.com/pocket-id/pocket-id/pull/1267) by @HiMoritz)
|
||||
- add `type="url"` to url inputs ([bb7b0d5](https://github.com/pocket-id/pocket-id/commit/bb7b0d56084df49b6a003cc3eaf076884e2cbf60) by @stonith404)
|
||||
- increase rate limit for frontend and api requests ([aab7e36](https://github.com/pocket-id/pocket-id/commit/aab7e364e85f1ce13950da93cc50324328cdd96d) by @stonith404)
|
||||
- decode URL-encoded client ID and secret in Basic auth ([#1263](https://github.com/pocket-id/pocket-id/pull/1263) by @ypomortsev)
|
||||
- token endpoint must not accept params as query string args ([#1321](https://github.com/pocket-id/pocket-id/pull/1321) by @ItalyPaleAle)
|
||||
- left align input error messages ([b3fe143](https://github.com/pocket-id/pocket-id/commit/b3fe14313684f9d8c389ed93ea8e479e3681b5c6) by @stonith404)
|
||||
- disallow API key renewal and creation with API key authentication ([#1334](https://github.com/pocket-id/pocket-id/pull/1334) by @stonith404)
|
||||
|
||||
### Features
|
||||
|
||||
- add VERSION_CHECK_DISABLED environment variable ([#1254](https://github.com/pocket-id/pocket-id/pull/1254) by @dihmandrake)
|
||||
- add support for HTTP/2 ([56afebc](https://github.com/pocket-id/pocket-id/commit/56afebc242be7ed14b58185425d6445bf18f640a) by @stonith404)
|
||||
- manageability of uncompressed geolite db file ([#1234](https://github.com/pocket-id/pocket-id/pull/1234) by @gucheen)
|
||||
- add JWT ID for generated tokens ([#1322](https://github.com/pocket-id/pocket-id/pull/1322) by @imnotjames)
|
||||
- current version api endpoint ([#1310](https://github.com/pocket-id/pocket-id/pull/1310) by @kmendell)
|
||||
|
||||
### Other
|
||||
|
||||
- bump @sveltejs/kit from 2.49.2 to 2.49.5 in the npm_and_yarn group across 1 directory ([#1240](https://github.com/pocket-id/pocket-id/pull/1240) by @dependabot[bot])
|
||||
- bump svelte from 5.46.1 to 5.46.4 in the npm_and_yarn group across 1 directory ([#1242](https://github.com/pocket-id/pocket-id/pull/1242) by @dependabot[bot])
|
||||
- bump devalue to 5.6.2 ([9dbc02e](https://github.com/pocket-id/pocket-id/commit/9dbc02e56871b2de6a39c443e1455efc26a949f7) by @kmendell)
|
||||
- upgrade deps ([4811625](https://github.com/pocket-id/pocket-id/commit/4811625cdd64b47ea67b7a9b03396e455896ccd6) by @kmendell)
|
||||
- add Estonian files ([53ef61a](https://github.com/pocket-id/pocket-id/commit/53ef61a3e5c4b77edec49d41ab94302bfec84269) by @kmendell)
|
||||
- update AAGUIDs ([#1257](https://github.com/pocket-id/pocket-id/pull/1257) by @github-actions[bot])
|
||||
- add Norwegian language files ([80558c5](https://github.com/pocket-id/pocket-id/commit/80558c562533e7b4d658d5baa4221d8cd209b47d) by @stonith404)
|
||||
- run formatter ([60825c5](https://github.com/pocket-id/pocket-id/commit/60825c5743b0e233ab622fd4d0ea04eb7ab59529) by @kmendell)
|
||||
- bump axios from 1.13.2 to 1.13.5 in the npm_and_yarn group across 1 directory ([#1309](https://github.com/pocket-id/pocket-id/pull/1309) by @dependabot[bot])
|
||||
- update dependenicies ([94a4897](https://github.com/pocket-id/pocket-id/commit/94a48977ba24e099b6221838d620c365eb1d4bf4) by @kmendell)
|
||||
- update AAGUIDs ([#1316](https://github.com/pocket-id/pocket-id/pull/1316) by @github-actions[bot])
|
||||
- bump svelte from 5.46.4 to 5.51.5 in the npm_and_yarn group across 1 directory ([#1324](https://github.com/pocket-id/pocket-id/pull/1324) by @dependabot[bot])
|
||||
- bump @sveltejs/kit from 2.49.5 to 2.52.2 in the npm_and_yarn group across 1 directory ([#1327](https://github.com/pocket-id/pocket-id/pull/1327) by @dependabot[bot])
|
||||
- upgrade dependencies ([0678699](https://github.com/pocket-id/pocket-id/commit/0678699d0cce5448c425b2c16bedab5fc242cbf0) by @stonith404)
|
||||
- upgrade to node 24 and go 1.26.0 ([#1328](https://github.com/pocket-id/pocket-id/pull/1328) by @kmendell)
|
||||
|
||||
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.2.0...v2.3.0
|
||||
|
||||
## v2.2.0
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -21,7 +21,6 @@ Before you submit the pull request for review please ensure that
|
||||
```
|
||||
|
||||
Where `TYPE` can be:
|
||||
|
||||
- **feat** - is a new feature
|
||||
- **doc** - documentation only changes
|
||||
- **fix** - a bug fix
|
||||
@@ -51,8 +50,8 @@ If you use [Dev Containers](https://code.visualstudio.com/docs/remote/containers
|
||||
|
||||
If you don't use Dev Containers, you need to install the following tools manually:
|
||||
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 22
|
||||
- [Go](https://golang.org/doc/install) >= 1.25
|
||||
- [Node.js](https://nodejs.org/en/download/) >= 24
|
||||
- [Go](https://golang.org/doc/install) >= 1.26
|
||||
- [Git](https://git-scm.com/downloads)
|
||||
|
||||
### 2. Setup
|
||||
|
||||
@@ -4,6 +4,6 @@ package frontend
|
||||
|
||||
import "github.com/gin-gonic/gin"
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
|
||||
return ErrFrontendNotIncluded
|
||||
}
|
||||
|
||||
@@ -8,8 +8,10 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"mime"
|
||||
"net/http"
|
||||
"os"
|
||||
"path"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -52,16 +54,23 @@ func init() {
|
||||
}
|
||||
}
|
||||
|
||||
func RegisterFrontend(router *gin.Engine) error {
|
||||
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error {
|
||||
distFS, err := fs.Sub(frontendFS, "dist")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create sub FS: %w", err)
|
||||
}
|
||||
|
||||
cacheMaxAge := time.Hour * 24
|
||||
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
|
||||
// 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)
|
||||
}
|
||||
|
||||
router.NoRoute(func(c *gin.Context) {
|
||||
// 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, "/")
|
||||
|
||||
if strings.HasSuffix(path, "/") {
|
||||
@@ -97,7 +106,9 @@ func RegisterFrontend(router *gin.Engine) error {
|
||||
// Serve other static assets with caching
|
||||
c.Request.URL.Path = "/" + path
|
||||
fileServer.ServeHTTP(c.Writer, c.Request)
|
||||
})
|
||||
}
|
||||
|
||||
router.NoRoute(rateLimitMiddleware, handler)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -106,34 +117,138 @@ func RegisterFrontend(router *gin.Engine) error {
|
||||
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
|
||||
}
|
||||
|
||||
143
backend/go.mod
143
backend/go.mod
@@ -1,24 +1,25 @@
|
||||
module github.com/pocket-id/pocket-id/backend
|
||||
|
||||
go 1.25
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
|
||||
github.com/aws/smithy-go v1.24.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.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/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/glebarez/go-sqlite v1.22.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/go-co-op/gocron/v2 v2.19.0
|
||||
github.com/go-co-op/gocron/v2 v2.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
|
||||
@@ -27,30 +28,31 @@ require (
|
||||
github.com/hashicorp/go-uuid v1.0.3
|
||||
github.com/jinzhu/copier v0.4.0
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.3
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.12
|
||||
github.com/lmittmann/tint v1.1.2
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.4
|
||||
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/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.14.0
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
|
||||
go.opentelemetry.io/otel v1.39.0
|
||||
go.opentelemetry.io/otel/log v0.15.0
|
||||
go.opentelemetry.io/otel/metric v1.39.0
|
||||
go.opentelemetry.io/otel/sdk v1.39.0
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0
|
||||
go.opentelemetry.io/otel/trace v1.39.0
|
||||
golang.org/x/crypto v0.46.0
|
||||
golang.org/x/image v0.34.0
|
||||
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.32.0
|
||||
golang.org/x/text v0.34.0
|
||||
golang.org/x/time v0.14.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -59,23 +61,24 @@ require (
|
||||
require (
|
||||
github.com/Azure/go-ntlmssp v0.1.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
|
||||
github.com/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.16 // 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.7 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/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/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bits-and-blooms/bitset v1.14.3 // indirect
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.14.2 // indirect
|
||||
github.com/bytedance/sonic/loader v0.4.0 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
@@ -84,22 +87,22 @@ require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.13 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
|
||||
github.com/go-webauthn/x v0.1.27 // indirect
|
||||
github.com/go-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/goccy/go-yaml v1.19.1 // indirect
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 // 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
|
||||
github.com/google/go-querystring v1.2.0 // indirect
|
||||
github.com/google/go-tpm v0.9.8 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -116,56 +119,56 @@ require (
|
||||
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.10.9 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.33 // indirect
|
||||
github.com/lib/pq v1.11.2 // indirect
|
||||
github.com/mattn/go-sqlite3 v1.14.34 // 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/nlnwa/whatwg-url v0.5.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.67.4 // indirect
|
||||
github.com/prometheus/common v0.67.5 // indirect
|
||||
github.com/prometheus/otlptranslator v1.0.0 // indirect
|
||||
github.com/prometheus/procfs v0.19.2 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.58.0 // indirect
|
||||
github.com/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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||
github.com/valyala/fastjson v1.6.7 // indirect
|
||||
github.com/valyala/fastjson v1.6.10 // indirect
|
||||
github.com/x448/float16 v0.8.4 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 // indirect
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 // indirect
|
||||
go.opentelemetry.io/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.23.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/oauth2 v0.34.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
|
||||
google.golang.org/grpc v1.78.0 // indirect
|
||||
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.3 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.67.4 // indirect
|
||||
modernc.org/libc v1.68.0 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
modernc.org/sqlite v1.42.2 // indirect
|
||||
modernc.org/sqlite v1.46.1 // indirect
|
||||
)
|
||||
|
||||
342
backend/go.sum
342
backend/go.sum
@@ -6,52 +6,55 @@ 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.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
|
||||
github.com/aws/aws-sdk-go-v2 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.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.6/go.mod h1:lcUL/gcd8WyjCrMnxez5OXkO3/rwcNmvfno62tnXNcI=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.6/go.mod h1:SgHzKjEVsdQr6Opor0ihgWtkWdfRAIwxYzSJ8O85VHY=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 h1:80+uETIWS1BqjnN9uJ0dBUaETh+P1XwFy5vwHwK5r9k=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16/go.mod h1:wOOsYuxYuB/7FlnVtzeBYRcjSRtQpAW0hCP7tIULMwo=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16 h1:rgGwPzb82iBYSvHMHXc8h9mRoOUBZIGFgKb9qniaZZc=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.16/go.mod h1:L/UxsGeKpGoIj6DxfhOWHWQ/kGKcd4I1VncE4++IyKA=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 h1:1jtGzuV7c82xnqOVfx2F0xmJcOw5374L7N6juGW6x6U=
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16/go.mod h1:M2E5OQf+XLe+SZGmmpaI2yy+J326aFf6/+54PoxSANc=
|
||||
github.com/aws/aws-sdk-go-v2/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.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
|
||||
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
|
||||
github.com/aws/aws-sdk-go-v2/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.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0/go.mod h1:79S2BdqCJpScXZA2y+cpZuocWsjGjJINyXnOsf5DTz8=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4 h1:HpI7aMmJ+mm1wkSHIA2t5EaFFv5EFYXePW30p1EIrbQ=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.0.4/go.mod h1:C5RdGMYGlfM0gYq/tifqgn4EbyX99V15P2V3R+VHbQU=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8 h1:aM/Q24rIlS3bRAhTyFurowU8A0SMyGDtEOY/l/s/1Uw=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.30.8/go.mod h1:+fWt2UHSb4kS7Pu8y+BMBvJF0EWx+4H0hzNwtDNRTrg=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12 h1:AHDr0DaHIAo8c9t1emrzAlVDFp+iMMKnPdYy6XO4MCE=
|
||||
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.12/go.mod h1:GQ73XawFFiWxyWXMHWfhiomvP3tXtdNar/fi8z18sx0=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 h1:SciGFVNZ4mHdm7gpD1dgZYnCuVdX1s+lFTg4+4DOy70=
|
||||
github.com/aws/aws-sdk-go-v2/service/sts v1.41.5/go.mod h1:iW40X4QBmUxdP+fZNOpfmkdMZqsovezbAeO+Ubiv2pk=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aws/aws-sdk-go-v2/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/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bits-and-blooms/bitset v1.13.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bits-and-blooms/bitset v1.14.3 h1:Gd2c8lSNf9pKXom5JtD7AaKO8o7fGQ2LtFj1436qilA=
|
||||
github.com/bits-and-blooms/bitset v1.14.3/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
|
||||
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
|
||||
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
|
||||
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/bytedance/sonic v1.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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
@@ -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=
|
||||
@@ -98,8 +103,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/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=
|
||||
@@ -112,8 +117,8 @@ github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GM
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.19.0 h1:OKf2y6LXPs/BgBI2fl8PxUpNAI1DA9Mg+hSeGOS38OU=
|
||||
github.com/go-co-op/gocron/v2 v2.19.0/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-co-op/gocron/v2 v2.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-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
@@ -129,20 +134,20 @@ github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJn
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
|
||||
github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||
github.com/go-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.1.27 h1:CLyuB8JGn9xvw0etBl4fnclcbPTwhKpN4Xg32zaSYnI=
|
||||
github.com/go-webauthn/x v0.1.27/go.mod h1:KGYJQAPPgbpDKi4N7zKMGL+Iz6WgxKg3OlhVbPtuJXI=
|
||||
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/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
|
||||
github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/goccy/go-yaml v1.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=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
|
||||
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
@@ -166,8 +171,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
|
||||
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
@@ -226,20 +231,20 @@ github.com/lestrrat-go/dsig-secp256k1 v1.0.0 h1:JpDe4Aybfl0soBvoVwjqDbp+9S1Y2OM7
|
||||
github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU=
|
||||
github.com/lestrrat-go/httpcc v1.0.1 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
|
||||
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.3 h1:WjLHWkDkgWXeIUrKi/7lS/sGq2DjkSAwdTbH5RHXAKs=
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.3/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
|
||||
github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
|
||||
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/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.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
|
||||
github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
|
||||
github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs=
|
||||
github.com/lib/pq v1.11.2/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.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
|
||||
github.com/mattn/go-sqlite3 v1.14.33/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mileusna/useragent v1.3.5 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
|
||||
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc=
|
||||
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
|
||||
@@ -257,6 +262,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.5.0 h1:l71cqfqG44+VCQZQX3wD4bwheFWicPxuwaCimLEfpDo=
|
||||
github.com/nlnwa/whatwg-url v0.5.0/go.mod h1:X/ejnFFVbaOWdSul+cnlsSHviCzGZJdvPkgc9zD8IY8=
|
||||
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=
|
||||
@@ -276,16 +283,16 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
|
||||
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
|
||||
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
|
||||
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/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
|
||||
github.com/quic-go/quic-go v0.58.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
@@ -316,62 +323,63 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
|
||||
github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
|
||||
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
|
||||
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.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
|
||||
go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 h1:7TYhBCu6Xz6vDJGNtEslWZLuuX2IJ/aH50hBY4MVeUg=
|
||||
go.opentelemetry.io/contrib/bridges/prometheus v0.64.0/go.mod h1:tHQctZfAe7e4PBPGyt3kae6mQFXNpj+iiDJa3ithM50=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0 h1:9pzPj3RFyKOxBAMkM2w84LpT+rdHam1XoFA+QhARiRw=
|
||||
go.opentelemetry.io/contrib/exporters/autoexport v0.64.0/go.mod h1:hlVZx1btWH0XTfXpuGX9dsquB50s+tc3fYFOO5elo2M=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0 h1:7IKZbAYwlwLXAdu7SVPhzTjDjogWZxP4MIa7rovY+PU=
|
||||
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0/go.mod h1:+TF5nf3NIv2X8PGxqfYOaRnAoMM43rUA2C3XsN2DoWA=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
|
||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
|
||||
go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0/go.mod h1:JM31r0GGZ/GU94mX8hN4D8v6e40aFlUECSQ48HaLgHM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0/go.mod h1:nWFP7C+T8TygkTjJ7mAyEaFaE7wNfms3nV/vexZ6qt0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0=
|
||||
go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 h1:0BSddrtQqLEylcErkeFrJBmwFzcqfQq9+/uxfTZq+HE=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0/go.mod h1:87sjYuAPzaRCtdd09GU5gM1U9wQLrrcYrm77mh5EBoc=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U=
|
||||
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g=
|
||||
go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
|
||||
go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
|
||||
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
|
||||
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
|
||||
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
|
||||
go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
|
||||
go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
|
||||
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
|
||||
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
|
||||
go.opentelemetry.io/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.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
@@ -381,55 +389,89 @@ go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y=
|
||||
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
|
||||
golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
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.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/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
|
||||
golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc=
|
||||
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4=
|
||||
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.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||
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.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
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.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||
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/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ=
|
||||
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
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.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
|
||||
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.8.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.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
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.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
|
||||
golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
|
||||
golang.org/x/text v0.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.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.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.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||
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=
|
||||
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:Xa7le7qx2vmqB/SzWUBa7KdMjpdpAHlh5QCSnjessQk=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
|
||||
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
|
||||
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
|
||||
google.golang.org/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.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
|
||||
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
|
||||
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=
|
||||
@@ -444,18 +486,18 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
|
||||
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
|
||||
modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
|
||||
modernc.org/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/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||
modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
|
||||
modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
|
||||
modernc.org/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.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
|
||||
modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
|
||||
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ=
|
||||
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0=
|
||||
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 +506,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
|
||||
modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
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
|
||||
|
||||
@@ -118,11 +118,10 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
|
||||
// Set the logger provider globally
|
||||
globallog.SetLoggerProvider(provider)
|
||||
|
||||
// Wrap the handler in a "fanout" one
|
||||
handler = utils.LogFanoutHandler{
|
||||
handler = slog.NewMultiHandler(
|
||||
handler,
|
||||
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
|
||||
}
|
||||
)
|
||||
|
||||
// Set the default slog to send logs to OTel and add the app name
|
||||
log := slog.New(handler).
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
sloggin "github.com/gin-contrib/slog"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||
"golang.org/x/net/http2"
|
||||
"golang.org/x/net/http2/h2c"
|
||||
"golang.org/x/time/rate"
|
||||
"gorm.io/gorm"
|
||||
|
||||
@@ -47,12 +49,14 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
_ = r.SetTrustedProxies(nil)
|
||||
}
|
||||
|
||||
if common.EnvConfig.TrustedPlatform != "" {
|
||||
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
|
||||
}
|
||||
|
||||
if common.EnvConfig.TracingEnabled {
|
||||
r.Use(otelgin.Middleware(common.Name))
|
||||
}
|
||||
|
||||
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
|
||||
|
||||
// Setup global middleware
|
||||
r.Use(middleware.HeadMiddleware())
|
||||
r.Use(middleware.NewCacheControlMiddleware().Add())
|
||||
@@ -60,7 +64,8 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
r.Use(middleware.NewCspMiddleware().Add())
|
||||
r.Use(middleware.NewErrorHandlerMiddleware().Add())
|
||||
|
||||
err := frontend.RegisterFrontend(r)
|
||||
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 {
|
||||
@@ -71,8 +76,10 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
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", rateLimitMiddleware)
|
||||
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)
|
||||
@@ -82,7 +89,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
|
||||
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
|
||||
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
|
||||
controller.NewVersionController(apiGroup, svc.versionService)
|
||||
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService)
|
||||
controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
|
||||
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
|
||||
|
||||
@@ -94,18 +101,23 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
}
|
||||
|
||||
// Set up base routes
|
||||
baseGroup := r.Group("/", rateLimitMiddleware)
|
||||
baseGroup := r.Group("/", apiRateLimitMiddleware)
|
||||
controller.NewWellKnownController(baseGroup, svc.jwtService)
|
||||
|
||||
// Set up healthcheck routes
|
||||
// These are not rate-limited
|
||||
controller.NewHealthzController(r)
|
||||
|
||||
var protocols http.Protocols
|
||||
protocols.SetHTTP1(true)
|
||||
protocols.SetUnencryptedHTTP2(true)
|
||||
|
||||
// Set up the server
|
||||
srv := &http.Server{
|
||||
MaxHeaderBytes: 1 << 20,
|
||||
ReadHeaderTimeout: 10 * time.Second,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||
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
|
||||
if req.Method == http.MethodHead {
|
||||
@@ -115,7 +127,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
|
||||
}
|
||||
|
||||
r.ServeHTTP(w, req)
|
||||
}),
|
||||
}), &http2.Server{}),
|
||||
}
|
||||
|
||||
// Set up the listener
|
||||
|
||||
@@ -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,12 +44,14 @@ 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"`
|
||||
InternalAppURL string `env:"INTERNAL_APP_URL"`
|
||||
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"`
|
||||
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
|
||||
VersionCheckDisabled bool `env:"VERSION_CHECK_DISABLED"`
|
||||
StaticApiKey string `env:"STATIC_API_KEY" options:"file"`
|
||||
|
||||
FileBackend string `env:"FILE_BACKEND" options:"toLower"`
|
||||
@@ -105,7 +107,7 @@ func defaultConfig() EnvConfigSchema {
|
||||
|
||||
func parseEnvConfig() error {
|
||||
parsers := map[reflect.Type]env.ParserFunc{
|
||||
reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
|
||||
reflect.TypeFor[[]byte](): func(value string) (any, error) {
|
||||
return []byte(value), nil
|
||||
},
|
||||
}
|
||||
@@ -128,6 +130,10 @@ func parseEnvConfig() error {
|
||||
|
||||
// ValidateEnvConfig checks the EnvConfig for required fields and valid values
|
||||
func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
if shouldSkipEnvValidation(os.Args) {
|
||||
return nil
|
||||
}
|
||||
|
||||
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
|
||||
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
|
||||
}
|
||||
@@ -179,8 +185,8 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
}
|
||||
|
||||
// Validate LOCAL_IPV6_RANGES
|
||||
ranges := strings.Split(config.LocalIPv6Ranges, ",")
|
||||
for _, rangeStr := range ranges {
|
||||
ranges := strings.SplitSeq(config.LocalIPv6Ranges, ",")
|
||||
for rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
@@ -209,6 +215,17 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
|
||||
}
|
||||
|
||||
func shouldSkipEnvValidation(args []string) bool {
|
||||
for _, arg := range args[1:] {
|
||||
switch arg {
|
||||
case "-h", "--help", "help", "version":
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// prepareEnvConfig processes special options for EnvConfig fields
|
||||
func prepareEnvConfig(config *EnvConfigSchema) error {
|
||||
val := reflect.ValueOf(config).Elem()
|
||||
@@ -219,9 +236,9 @@ func prepareEnvConfig(config *EnvConfigSchema) error {
|
||||
fieldType := typ.Field(i)
|
||||
|
||||
optionsTag := fieldType.Tag.Get("options")
|
||||
options := strings.Split(optionsTag, ",")
|
||||
options := strings.SplitSeq(optionsTag, ",")
|
||||
|
||||
for _, option := range options {
|
||||
for option := range options {
|
||||
switch option {
|
||||
case "toLower":
|
||||
if field.Kind() == reflect.String {
|
||||
|
||||
@@ -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
|
||||
@@ -31,26 +31,26 @@ func (e *AlreadyInUseError) Is(target error) bool {
|
||||
type SetupAlreadyCompletedError struct{}
|
||||
|
||||
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
|
||||
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
|
||||
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return http.StatusConflict }
|
||||
|
||||
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 {
|
||||
@@ -280,6 +299,13 @@ func (e *APIKeyExpirationDateError) Error() string {
|
||||
}
|
||||
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest }
|
||||
|
||||
type APIKeyAuthNotAllowedError struct{}
|
||||
|
||||
func (e *APIKeyAuthNotAllowedError) Error() string {
|
||||
return "API key authentication is not allowed for this endpoint"
|
||||
}
|
||||
func (e *APIKeyAuthNotAllowedError) HttpStatusCode() int { return http.StatusForbidden }
|
||||
|
||||
type OidcInvalidRefreshTokenError struct{}
|
||||
|
||||
func (e *OidcInvalidRefreshTokenError) Error() string {
|
||||
|
||||
@@ -26,12 +26,11 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
|
||||
uc := &ApiKeyController{apiKeyService: apiKeyService}
|
||||
|
||||
apiKeyGroup := group.Group("/api-keys")
|
||||
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
|
||||
{
|
||||
apiKeyGroup.GET("", uc.listApiKeysHandler)
|
||||
apiKeyGroup.POST("", uc.createApiKeyHandler)
|
||||
apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
|
||||
apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
|
||||
apiKeyGroup.GET("", authMiddleware.WithAdminNotRequired().Add(), uc.listApiKeysHandler)
|
||||
apiKeyGroup.POST("", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.createApiKeyHandler)
|
||||
apiKeyGroup.POST("/:id/renew", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.renewApiKeyHandler)
|
||||
apiKeyGroup.DELETE("/:id", authMiddleware.WithAdminNotRequired().Add(), uc.revokeApiKeyHandler)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
@@ -24,7 +25,11 @@ import (
|
||||
// @Description Initializes all OIDC-related API endpoints for authentication and client management
|
||||
// @Tags OIDC
|
||||
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
|
||||
oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
|
||||
oc := &OidcController{
|
||||
oidcService: oidcService,
|
||||
jwtService: jwtService,
|
||||
createTokens: oidcService.CreateTokens,
|
||||
}
|
||||
|
||||
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
|
||||
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
|
||||
@@ -47,7 +52,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
|
||||
|
||||
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
|
||||
group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
|
||||
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler)
|
||||
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler)
|
||||
|
||||
group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
|
||||
@@ -68,8 +73,9 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
|
||||
}
|
||||
|
||||
type OidcController struct {
|
||||
oidcService *service.OidcService
|
||||
jwtService *service.JwtService
|
||||
oidcService *service.OidcService
|
||||
jwtService *service.JwtService
|
||||
createTokens func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error)
|
||||
}
|
||||
|
||||
// authorizeHandler godoc
|
||||
@@ -144,8 +150,13 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
|
||||
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
|
||||
// @Router /api/oidc/token [post]
|
||||
func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
// Per RFC-6749, parameters passed to the /token endpoint MUST be passed in the body of the 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.OidcCreateTokensDto
|
||||
if err := c.ShouldBind(&input); err != nil {
|
||||
err := c.ShouldBind(&input)
|
||||
if err != nil {
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
@@ -164,10 +175,10 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if input.ClientID == "" && input.ClientSecret == "" {
|
||||
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
|
||||
}
|
||||
|
||||
tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
|
||||
tokens, err := oc.createTokens(c.Request.Context(), input)
|
||||
|
||||
switch {
|
||||
case errors.Is(err, &common.OidcAuthorizationPendingError{}):
|
||||
@@ -322,13 +333,15 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
|
||||
creds service.ClientAuthCredentials
|
||||
ok bool
|
||||
)
|
||||
creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -651,15 +664,20 @@ 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
|
||||
}
|
||||
|
||||
// Client id and secret can also be passed over the Authorization header
|
||||
if input.ClientID == "" && input.ClientSecret == "" {
|
||||
input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
|
||||
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request)
|
||||
}
|
||||
|
||||
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)
|
||||
|
||||
227
backend/internal/controller/oidc_controller_test.go
Normal file
227
backend/internal/controller/oidc_controller_test.go
Normal file
@@ -0,0 +1,227 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
)
|
||||
|
||||
func TestCreateTokensHandler(t *testing.T) {
|
||||
createTestContext := func(t *testing.T, rawURL string, form url.Values, authHeader string, noCT bool) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
|
||||
mode := gin.Mode()
|
||||
gin.SetMode(gin.TestMode)
|
||||
t.Cleanup(func() { gin.SetMode(mode) })
|
||||
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, rawURL, strings.NewReader(form.Encode()))
|
||||
require.NoError(t, err)
|
||||
|
||||
if !noCT {
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
}
|
||||
if authHeader != "" {
|
||||
req.Header.Set("Authorization", authHeader)
|
||||
}
|
||||
|
||||
c.Request = req
|
||||
return c, recorder
|
||||
}
|
||||
|
||||
t.Run("Ignores Query String Parameters For Binding", func(t *testing.T) {
|
||||
oc := &OidcController{}
|
||||
|
||||
c, _ := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token?grant_type=refresh_token&refresh_token=query-value",
|
||||
url.Values{},
|
||||
"",
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Len(t, c.Errors, 1)
|
||||
assert.Contains(t, c.Errors[0].Err.Error(), "GrantType")
|
||||
})
|
||||
|
||||
t.Run("Missing Authorization Code", func(t *testing.T) {
|
||||
oc := &OidcController{}
|
||||
|
||||
c, _ := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token",
|
||||
url.Values{
|
||||
"grant_type": {service.GrantTypeAuthorizationCode},
|
||||
},
|
||||
"",
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Len(t, c.Errors, 1)
|
||||
var missingCodeErr *common.OidcMissingAuthorizationCodeError
|
||||
require.ErrorAs(t, c.Errors[0].Err, &missingCodeErr)
|
||||
})
|
||||
|
||||
t.Run("Missing Refresh Token", func(t *testing.T) {
|
||||
oc := &OidcController{}
|
||||
|
||||
c, _ := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token",
|
||||
url.Values{
|
||||
"grant_type": {service.GrantTypeRefreshToken},
|
||||
},
|
||||
"",
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Len(t, c.Errors, 1)
|
||||
var missingRefreshErr *common.OidcMissingRefreshTokenError
|
||||
require.ErrorAs(t, c.Errors[0].Err, &missingRefreshErr)
|
||||
})
|
||||
|
||||
t.Run("Uses Basic Auth Credentials When Body Credentials Missing", func(t *testing.T) {
|
||||
var capturedInput dto.OidcCreateTokensDto
|
||||
oc := &OidcController{
|
||||
createTokens: func(_ context.Context, input dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
|
||||
capturedInput = input
|
||||
return service.CreatedTokens{
|
||||
AccessToken: "access-token",
|
||||
IdToken: "id-token",
|
||||
RefreshToken: "refresh-token",
|
||||
ExpiresIn: 2 * time.Minute,
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
basicAuth := "Basic " + base64.StdEncoding.EncodeToString([]byte("client-id:client-secret"))
|
||||
c, recorder := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token",
|
||||
url.Values{
|
||||
"grant_type": {service.GrantTypeRefreshToken},
|
||||
"refresh_token": {"input-refresh-token"},
|
||||
},
|
||||
basicAuth,
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Empty(t, c.Errors)
|
||||
assert.Equal(t, "client-id", capturedInput.ClientID)
|
||||
assert.Equal(t, "client-secret", capturedInput.ClientSecret)
|
||||
assert.Equal(t, "input-refresh-token", capturedInput.RefreshToken)
|
||||
|
||||
require.Equal(t, http.StatusOK, recorder.Code)
|
||||
var response dto.OidcTokenResponseDto
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.Equal(t, "access-token", response.AccessToken)
|
||||
assert.Equal(t, "Bearer", response.TokenType)
|
||||
assert.Equal(t, "id-token", response.IdToken)
|
||||
assert.Equal(t, "refresh-token", response.RefreshToken)
|
||||
assert.Equal(t, 120, response.ExpiresIn)
|
||||
})
|
||||
|
||||
t.Run("Maps Authorization Pending Error", func(t *testing.T) {
|
||||
oc := &OidcController{
|
||||
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
|
||||
return service.CreatedTokens{}, &common.OidcAuthorizationPendingError{}
|
||||
},
|
||||
}
|
||||
|
||||
c, recorder := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token",
|
||||
url.Values{
|
||||
"grant_type": {service.GrantTypeRefreshToken},
|
||||
"refresh_token": {"input-refresh-token"},
|
||||
},
|
||||
"",
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
var response map[string]string
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.Equal(t, "authorization_pending", response["error"])
|
||||
})
|
||||
|
||||
t.Run("Maps Slow Down Error", func(t *testing.T) {
|
||||
oc := &OidcController{
|
||||
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
|
||||
return service.CreatedTokens{}, &common.OidcSlowDownError{}
|
||||
},
|
||||
}
|
||||
|
||||
c, recorder := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token",
|
||||
url.Values{
|
||||
"grant_type": {service.GrantTypeRefreshToken},
|
||||
"refresh_token": {"input-refresh-token"},
|
||||
},
|
||||
"",
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Empty(t, c.Errors)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
var response map[string]string
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
assert.Equal(t, "slow_down", response["error"])
|
||||
})
|
||||
|
||||
t.Run("Returns Generic Service Error In Context", func(t *testing.T) {
|
||||
expectedErr := errors.New("boom")
|
||||
oc := &OidcController{
|
||||
createTokens: func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error) {
|
||||
return service.CreatedTokens{}, expectedErr
|
||||
},
|
||||
}
|
||||
|
||||
c, _ := createTestContext(
|
||||
t,
|
||||
"http://example.com/oidc/token",
|
||||
url.Values{
|
||||
"grant_type": {service.GrantTypeRefreshToken},
|
||||
"refresh_token": {"input-refresh-token"},
|
||||
},
|
||||
"",
|
||||
false,
|
||||
)
|
||||
|
||||
oc.createTokensHandler(c)
|
||||
|
||||
require.Len(t, c.Errors, 1)
|
||||
assert.ErrorIs(t, c.Errors[0].Err, expectedErr)
|
||||
})
|
||||
}
|
||||
@@ -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"
|
||||
@@ -322,22 +323,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
|
||||
|
||||
@@ -5,14 +5,17 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/middleware"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/service"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
// NewVersionController registers version-related routes.
|
||||
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
|
||||
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) {
|
||||
vc := &VersionController{versionService: versionService}
|
||||
group.GET("/version/latest", vc.getLatestVersionHandler)
|
||||
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
|
||||
}
|
||||
|
||||
type VersionController struct {
|
||||
@@ -38,3 +41,16 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
|
||||
"latestVersion": tag,
|
||||
})
|
||||
}
|
||||
|
||||
// getCurrentVersionHandler godoc
|
||||
// @Summary Get current deployed version of Pocket ID
|
||||
// @Tags Version
|
||||
// @Produce json
|
||||
// @Success 200 {object} map[string]string "Current version information"
|
||||
// @Router /api/version/current [get]
|
||||
func (vc *VersionController) getCurrentVersionHandler(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"currentVersion": common.Version,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ 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"},
|
||||
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
|
||||
}
|
||||
return json.Marshal(config)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
type sourceStruct struct {
|
||||
@@ -60,11 +59,11 @@ type embeddedStruct struct {
|
||||
func TestMapStruct(t *testing.T) {
|
||||
src := sourceStruct{
|
||||
AString: "abcd",
|
||||
AStringPtr: utils.Ptr("xyz"),
|
||||
AStringPtr: new("xyz"),
|
||||
ABool: true,
|
||||
ABoolPtr: utils.Ptr(false),
|
||||
ABoolPtr: new(false),
|
||||
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
|
||||
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||
ANilStringPtr: nil,
|
||||
ASlice: []string{"a", "b", "c"},
|
||||
AMap: map[string]int{
|
||||
@@ -80,8 +79,8 @@ func TestMapStruct(t *testing.T) {
|
||||
Bar: 111,
|
||||
},
|
||||
|
||||
StringPtrToString: utils.Ptr("foobar"),
|
||||
EmptyStringPtrToString: utils.Ptr(""),
|
||||
StringPtrToString: new("foobar"),
|
||||
EmptyStringPtrToString: new(""),
|
||||
NilStringPtrToString: nil,
|
||||
IntToInt64: 99,
|
||||
AuditLogEventToString: model.AuditLogEventAccountCreated,
|
||||
@@ -118,11 +117,11 @@ func TestMapStructList(t *testing.T) {
|
||||
sources := []sourceStruct{
|
||||
{
|
||||
AString: "first",
|
||||
AStringPtr: utils.Ptr("one"),
|
||||
AStringPtr: new("one"),
|
||||
ABool: true,
|
||||
ABoolPtr: utils.Ptr(false),
|
||||
ABoolPtr: new(false),
|
||||
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
|
||||
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
|
||||
ASlice: []string{"a", "b"},
|
||||
AMap: map[string]int{
|
||||
"a": 1,
|
||||
@@ -136,11 +135,11 @@ func TestMapStructList(t *testing.T) {
|
||||
},
|
||||
{
|
||||
AString: "second",
|
||||
AStringPtr: utils.Ptr("two"),
|
||||
AStringPtr: new("two"),
|
||||
ABool: false,
|
||||
ABoolPtr: utils.Ptr(true),
|
||||
ABoolPtr: new(true),
|
||||
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
|
||||
ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
|
||||
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
|
||||
ASlice: []string{"c", "d", "e"},
|
||||
AMap: map[string]int{
|
||||
"c": 3,
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
|
||||
func Normalize(obj any) {
|
||||
v := reflect.ValueOf(obj)
|
||||
if v.Kind() != reflect.Ptr || v.IsNil() {
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() {
|
||||
return
|
||||
}
|
||||
v = v.Elem()
|
||||
@@ -21,7 +21,7 @@ func Normalize(obj any) {
|
||||
if v.Kind() == reflect.Slice {
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
elem := v.Index(i)
|
||||
if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
|
||||
if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
|
||||
Normalize(elem.Interface())
|
||||
} else if elem.Kind() == reflect.Struct && elem.CanAddr() {
|
||||
Normalize(elem.Addr().Interface())
|
||||
|
||||
@@ -98,7 +98,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 {
|
||||
|
||||
@@ -67,7 +67,7 @@ type ScimResourceData struct {
|
||||
type ScimResourceMeta struct {
|
||||
Location string `json:"location,omitempty"`
|
||||
ResourceType string `json:"resourceType,omitempty"`
|
||||
Created time.Time `json:"created,omitempty"`
|
||||
Created time.Time `json:"created"`
|
||||
LastModified time.Time `json:"lastModified,omitempty"`
|
||||
Version string `json:"version,omitempty"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -3,7 +3,6 @@ package dto
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -17,7 +16,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "valid input",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -27,27 +26,37 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
{
|
||||
name: "missing username",
|
||||
input: UserCreateDto{
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
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{
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
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",
|
||||
input: UserCreateDto{
|
||||
Username: "test/ser",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -58,7 +67,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "invalid email",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: utils.Ptr("not-an-email"),
|
||||
Email: new("not-an-email"),
|
||||
FirstName: "John",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
@@ -69,18 +78,18 @@ func TestUserCreateDto_Validate(t *testing.T) {
|
||||
name: "first name too short",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "",
|
||||
LastName: "Doe",
|
||||
DisplayName: "John Doe",
|
||||
},
|
||||
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "last name too long",
|
||||
input: UserCreateDto{
|
||||
Username: "testuser",
|
||||
Email: utils.Ptr("test@example.com"),
|
||||
Email: new("test@example.com"),
|
||||
FirstName: "John",
|
||||
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
|
||||
DisplayName: "John Doe",
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
@@ -15,7 +13,8 @@ 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._-]+$")
|
||||
|
||||
@@ -67,19 +66,6 @@ func ValidateClientID(clientID string) bool {
|
||||
|
||||
// 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)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if !u.IsAbs() {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
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},
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -18,6 +18,7 @@ type AuthMiddleware struct {
|
||||
type AuthOptions struct {
|
||||
AdminRequired bool
|
||||
SuccessOptional bool
|
||||
AllowApiKeyAuth bool
|
||||
}
|
||||
|
||||
func NewAuthMiddleware(
|
||||
@@ -31,6 +32,7 @@ func NewAuthMiddleware(
|
||||
options: AuthOptions{
|
||||
AdminRequired: true,
|
||||
SuccessOptional: false,
|
||||
AllowApiKeyAuth: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -59,6 +61,17 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
|
||||
return clone
|
||||
}
|
||||
|
||||
// WithApiKeyAuthDisabled disables API key authentication fallback and requires JWT auth.
|
||||
func (m *AuthMiddleware) WithApiKeyAuthDisabled() *AuthMiddleware {
|
||||
clone := &AuthMiddleware{
|
||||
apiKeyMiddleware: m.apiKeyMiddleware,
|
||||
jwtMiddleware: m.jwtMiddleware,
|
||||
options: m.options,
|
||||
}
|
||||
clone.options.AllowApiKeyAuth = false
|
||||
return clone
|
||||
}
|
||||
|
||||
func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
|
||||
@@ -79,6 +92,21 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
if !m.options.AllowApiKeyAuth {
|
||||
if m.options.SuccessOptional {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
c.Abort()
|
||||
if c.GetHeader("X-API-Key") != "" {
|
||||
_ = c.Error(&common.APIKeyAuthNotAllowedError{})
|
||||
return
|
||||
}
|
||||
_ = c.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
// JWT auth failed, try API key auth
|
||||
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
|
||||
if err == nil {
|
||||
|
||||
104
backend/internal/middleware/auth_middleware_test.go
Normal file
104
backend/internal/middleware/auth_middleware_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"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"
|
||||
"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"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func TestWithApiKeyAuthDisabled(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
originalEnvConfig := common.EnvConfig
|
||||
defer func() {
|
||||
common.EnvConfig = originalEnvConfig
|
||||
}()
|
||||
common.EnvConfig.AppURL = "https://test.example.com"
|
||||
common.EnvConfig.EncryptionKey = []byte("0123456789abcdef0123456789abcdef")
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
appConfigService, err := service.NewAppConfigService(t.Context(), db)
|
||||
require.NoError(t, err)
|
||||
|
||||
jwtService, err := service.NewJwtService(t.Context(), db, appConfigService)
|
||||
require.NoError(t, err)
|
||||
|
||||
userService := service.NewUserService(db, jwtService, nil, nil, appConfigService, nil, nil, nil, nil)
|
||||
apiKeyService, err := service.NewApiKeyService(t.Context(), db, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
authMiddleware := NewAuthMiddleware(apiKeyService, userService, jwtService)
|
||||
|
||||
user := createUserForAuthMiddlewareTest(t, db)
|
||||
jwtToken, err := jwtService.GenerateAccessToken(user)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, apiKeyToken, err := apiKeyService.CreateApiKey(t.Context(), user.ID, dto.ApiKeyCreateDto{
|
||||
Name: "Middleware API Key",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
router := gin.New()
|
||||
router.Use(NewErrorHandlerMiddleware().Add())
|
||||
router.GET("/api/protected", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), func(c *gin.Context) {
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
|
||||
t.Run("rejects API key auth when API key auth is disabled", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
|
||||
req.Header.Set("X-API-Key", apiKeyToken)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, recorder.Code)
|
||||
|
||||
var body map[string]string
|
||||
err := json.Unmarshal(recorder.Body.Bytes(), &body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "API key authentication is not allowed for this endpoint", body["error"])
|
||||
})
|
||||
|
||||
t.Run("allows JWT auth when API key auth is disabled", func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/protected", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+jwtToken)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(recorder, req)
|
||||
|
||||
require.Equal(t, http.StatusNoContent, recorder.Code)
|
||||
})
|
||||
}
|
||||
|
||||
func createUserForAuthMiddlewareTest(t *testing.T, db *gorm.DB) model.User {
|
||||
t.Helper()
|
||||
|
||||
email := "auth@example.com"
|
||||
user := model.User{
|
||||
Username: "auth-user",
|
||||
Email: &email,
|
||||
FirstName: "Auth",
|
||||
LastName: "User",
|
||||
DisplayName: "Auth User",
|
||||
}
|
||||
|
||||
err := db.Create(&user).Error
|
||||
require.NoError(t, err)
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -70,13 +70,12 @@ func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
|
||||
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig
|
||||
// This test is primarily meant to catch discrepancies between the two structs as fields are added or removed over time
|
||||
func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
|
||||
appConfigType := reflect.TypeOf(model.AppConfig{})
|
||||
updateDtoType := reflect.TypeOf(dto.AppConfigUpdateDto{})
|
||||
appConfigType := reflect.TypeFor[model.AppConfig]()
|
||||
updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]()
|
||||
|
||||
// Process AppConfig fields
|
||||
appConfigFields := make(map[string]string)
|
||||
for i := 0; i < appConfigType.NumField(); i++ {
|
||||
field := appConfigType.Field(i)
|
||||
for field := range appConfigType.Fields() {
|
||||
if field.Tag.Get("key") == "" {
|
||||
// Skip internal fields
|
||||
continue
|
||||
@@ -91,9 +90,7 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
|
||||
|
||||
// Process AppConfigUpdateDto fields
|
||||
dtoFields := make(map[string]string)
|
||||
for i := 0; i < updateDtoType.NumField(); i++ {
|
||||
field := updateDtoType.Field(i)
|
||||
|
||||
for field := range updateDtoType.Fields() {
|
||||
// Extract the json name from the tag (takes the part before any binding constraints)
|
||||
jsonTag := field.Tag.Get("json")
|
||||
jsonName, _, _ := strings.Cut(jsonTag, ",")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -58,7 +58,7 @@ type ReauthenticationToken struct {
|
||||
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
|
||||
func (atl *AuthenticatorTransportList) Scan(value any) error {
|
||||
return utils.UnmarshalJSONFromDatabase(atl, value)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
|
||||
type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck
|
||||
|
||||
// Scan and Value methods for GORM to handle the custom type
|
||||
func (cp *CredentialParameters) Scan(value interface{}) error {
|
||||
func (cp *CredentialParameters) Scan(value any) error {
|
||||
return utils.UnmarshalJSONFromDatabase(cp, value)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
|
||||
@@ -77,6 +78,9 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
|
||||
Create(&apiKey).
|
||||
Error
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrDuplicatedKey) {
|
||||
return model.ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
|
||||
}
|
||||
return model.ApiKey{}, "", err
|
||||
}
|
||||
|
||||
@@ -170,7 +174,7 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
|
||||
Clauses(clause.Returning{}).
|
||||
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
|
||||
Updates(&model.ApiKey{
|
||||
LastUsedAt: utils.Ptr(datatype.DateTime(now)),
|
||||
LastUsedAt: new(datatype.DateTime(now)),
|
||||
}).
|
||||
Preload("User").
|
||||
First(&key).
|
||||
@@ -202,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) {
|
||||
|
||||
@@ -186,8 +186,7 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
|
||||
rt := reflect.ValueOf(input).Type()
|
||||
rv := reflect.ValueOf(input)
|
||||
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
|
||||
for i := range rt.NumField() {
|
||||
field := rt.Field(i)
|
||||
for field := range rt.Fields() {
|
||||
value := rv.FieldByName(field.Name).String()
|
||||
|
||||
// Get the value of the json tag, taking only what's before the comma
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
|
||||
},
|
||||
Username: "tim",
|
||||
Email: utils.Ptr("tim.cook@test.com"),
|
||||
Email: new("tim.cook@test.com"),
|
||||
EmailVerified: true,
|
||||
FirstName: "Tim",
|
||||
LastName: "Cook",
|
||||
@@ -93,7 +93,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
|
||||
},
|
||||
Username: "craig",
|
||||
Email: utils.Ptr("craig.federighi@test.com"),
|
||||
Email: new("craig.federighi@test.com"),
|
||||
EmailVerified: false,
|
||||
FirstName: "Craig",
|
||||
LastName: "Federighi",
|
||||
@@ -105,7 +105,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
|
||||
},
|
||||
Username: "eddy",
|
||||
Email: utils.Ptr("eddy.cue@test.com"),
|
||||
Email: new("eddy.cue@test.com"),
|
||||
FirstName: "Eddy",
|
||||
LastName: "Cue",
|
||||
DisplayName: "Eddy Cue",
|
||||
@@ -171,12 +171,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
|
||||
},
|
||||
Name: "Nextcloud",
|
||||
LaunchURL: utils.Ptr("https://nextcloud.local"),
|
||||
LaunchURL: new("https://nextcloud.local"),
|
||||
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
|
||||
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
|
||||
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
|
||||
ImageType: utils.StringPointer("png"),
|
||||
CreatedByID: utils.Ptr(users[0].ID),
|
||||
ImageType: new("png"),
|
||||
CreatedByID: new(users[0].ID),
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -185,7 +185,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Name: "Immich",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.UrlList{"http://immich/auth/callback"},
|
||||
CreatedByID: utils.Ptr(users[1].ID),
|
||||
CreatedByID: new(users[1].ID),
|
||||
IsGroupRestricted: true,
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[1],
|
||||
@@ -200,7 +200,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
|
||||
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
|
||||
IsGroupRestricted: true,
|
||||
CreatedByID: utils.Ptr(users[0].ID),
|
||||
CreatedByID: new(users[0].ID),
|
||||
},
|
||||
{
|
||||
Base: model.Base{
|
||||
@@ -209,7 +209,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Name: "Federated",
|
||||
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
|
||||
CallbackURLs: model.UrlList{"http://federated/auth/callback"},
|
||||
CreatedByID: utils.Ptr(users[1].ID),
|
||||
CreatedByID: new(users[1].ID),
|
||||
AllowedUserGroups: []model.UserGroup{},
|
||||
Credentials: model.OidcClientCredentials{
|
||||
FederatedIdentities: []model.OidcClientFederatedIdentity{
|
||||
@@ -229,7 +229,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Name: "SCIM Client",
|
||||
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
|
||||
CallbackURLs: model.UrlList{"http://scimclient/auth/callback"},
|
||||
CreatedByID: utils.Ptr(users[0].ID),
|
||||
CreatedByID: new(users[0].ID),
|
||||
IsGroupRestricted: true,
|
||||
AllowedUserGroups: []model.UserGroup{
|
||||
userGroups[0],
|
||||
@@ -258,7 +258,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
Nonce: "nonce",
|
||||
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
|
||||
UserID: users[1].ID,
|
||||
ClientID: oidcClients[2].ID,
|
||||
ClientID: oidcClients[3].ID,
|
||||
},
|
||||
}
|
||||
for _, authCode := range authCodes {
|
||||
@@ -458,7 +458,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
|
||||
{
|
||||
Key: jwkutils.PrivateKeyDBKey,
|
||||
// {"alg":"RS256","d":"mvMDWSdPPvcum0c0iEHE2gbqtV2NKMmLwrl9E6K7g8lTV95SePLnW_bwyMPV7EGp7PQk3l17I5XRhFjze7GqTnFIOgKzMianPs7jv2ELtBMGK0xOPATgu1iGb70xZ6vcvuEfRyY3dJ0zr4jpUdVuXwKmx9rK4IdZn2dFCKfvSuspqIpz11RhF1ALrqDLkxGVv7ZwNh0_VhJZU9hcjG5l6xc7rQEKpPRkZp0IdjkGS8Z0FskoVaiRIWAbZuiVFB9WCW8k1czC4HQTPLpII01bUQx2ludbm0UlXRgVU9ptUUbU7GAImQqTOW8LfPGklEvcgzlIlR_oqw4P9yBxLi-yMQ","dp":"pvNCSnnhbo8Igw9psPR-DicxFnkXlu_ix4gpy6efTrxA-z1VDFDioJ814vKQNioYDzpyAP1gfMPhRkvG_q0hRZsJah3Sb9dfA-WkhSWY7lURQP4yIBTMU0PF_rEATuS7lRciYk1SOx5fqXZd3m_LP0vpBC4Ujlq6NAq6CIjCnms","dq":"TtUVGCCkPNgfOLmkYXu7dxxUCV5kB01-xAEK2OY0n0pG8vfDophH4_D_ZC7nvJ8J9uDhs_3JStexq1lIvaWtG99RNTChIEDzpdn6GH9yaVcb_eB4uJjrNm64FhF8PGCCwxA-xMCZMaARKwhMB2_IOMkxUbWboL3gnhJ2rDO_QO0","e":"AQAB","kid":"8uHDw3M6rf8","kty":"RSA","n":"yaeEL0VKoPBXIAaWXsUgmu05lAvEIIdJn0FX9lHh4JE5UY9B83C5sCNdhs9iSWzpeP11EVjWp8i3Yv2CF7c7u50BXnVBGtxpZpFC-585UXacoJ0chUmarL9GRFJcM1nPHBTFu68aRrn1rIKNHUkNaaxFo0NFGl_4EDDTO8HwawTjwkPoQlRzeByhlvGPVvwgB3Fn93B8QJ_cZhXKxJvjjrC_8Pk76heC_ntEMru71Ix77BoC3j2TuyiN7m9RNBW8BU5q6lKoIdvIeZfTFLzi37iufyfvMrJTixp9zhNB1NxlLCeOZl2MXegtiGqd2H3cbAyqoOiv9ihUWTfXj7SxJw","p":"_Yylc9e07CKdqNRD2EosMC2mrhrEa9j5oY_l00Qyy4-jmCA59Q9viyqvveRo0U7cRvFA5BWgWN6GGLh1DG3X-QBqVr0dnk3uzbobb55RYUXyPLuBZI2q6w2oasbiDwPdY7KpkVv_H-bpITQlyDvO8hhucA6rUV7F6KTQVz8M3Ms","q":"y5p3hch-7jJ21TkAhp_Vk1fLCAuD4tbErwQs2of9ja8sB4iJOs5Wn6HD3P7Mc8Plye7qaLHvzc8I5g0tPKWvC0DPd_FLPXiWwMVAzee3NUX_oGeJNOQp11y1w_KqdO9qZqHSEPZ3NcFL_SZMFgggxhM1uzRiPzsVN0lnD_6prZU","qi":"2Grt6uXHm61ji3xSdkBWNtUnj19vS1-7rFJp5SoYztVQVThf_W52BAiXKBdYZDRVoItC_VS2NvAOjeJjhYO_xQ_q3hK7MdtuXfEPpLnyXKkmWo3lrJ26wbeF6l05LexCkI7ShsOuSt-dsyaTJTszuKDIA6YOfWvfo3aVZmlWRaI","use":"sig"}
|
||||
Value: utils.Ptr("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
|
||||
Value: new("7d/5hl7diJ2rnFL14hEAQf9tzpu29aqXQ8jpJ2iqqKUNFZpdOkEpud0CmRv4H3r8yyk2u/Gqqj9klSy58DJkYXGF5PAYgLyoBIb7L3JXWRbxg4cQ3QJCug13l2OTmpAKoVc+rmX8c3j3h1sNqyJ+7Ql5sS0jSeyiYgIsFNCdnK5alBDyvtcpe/QDpklmP4JCeVpvmf2rLGplk3g5UO5ydJ8UiDXxfDmi+gF6NKJvrGnnah8Ar3G/x88z+tTJtp0DIQFwxXwUM2XZqzEVGm8K2r0w5o9/Keh6bBBaiuH2C78ZOaijGV3DovhR+e9J0cYUYGwT42MZMx9fSWQ/lvWGGnf+Uq3MXJfjWSREfhkp8KTQwR9F7+dnVJWswOEk7jPR8I7hCWTMxJyvaFX3wgAXIVmhrgXZQQbYOqTt56IoqUl0xOJku8dA8opg2UcLlmmuOh6+hfkXKsiiS/H/9c1BVIGj1fCOiT6IePh4wKKSTbwJnPD5EKmdJpgTsUpjcDnXQKY4ReO0UpdRdKxwRDDLeQuG6j+ljGxR9GPudCU9Nmci6rFVI6n5LWYkQxBA1O73RpmXRZPDzntDfpXMEonkmSvOoxaCK2Id7CRKMdqvR0kEouwnhk5WSFtsfi3sA0pkXzPFxwZeWM8vFtbffZOZzXaOhxCOfcj1NClZohlZhyc4jvkxmrpY7PSaAzih0AmHI7y0LYFi6fZu/K4EheVa1+KF55nWZ8ARikHMWKAKkyExkTak7xyN884TDmzURRaPlQg4jzQte5WMNjAG/hlHibdMBNvgwiYd49ZxteJ8ABdbiXVRl+2JGbdjl2ubpQZwOn7bJKlqO56bIwsZ+e4+pXsuOGdBahkHrUjtMEmH3DZbGc6CJLbcmdhdpApLQRRcLAazxJhzAwJ47FRYsHsj57LnYNvmcKdIxw8rxCdLUuzz95uw0T3ankEO5J9sjem+HMEuKdwXK1UcuOn2rjR8Sd/BuvQmeso27dFbPXqXYNS90Ml45YyTvcKSiopD181oZR703TFUSpR7dsiqROMr+p/2jN9h6a8WbQ8xpksyclaQByY/M77AssbXnG6wfhRsntNIINCZLbBnjXOyz6ZHIC5K4tSTdcnWaiYPeRPQmnw9UUvHAcNU2yMWsy0eU377yDS0WstTxOdQutTdkczl8kv5Lo26JiEK7mSIuRK19ffF9Zz8FG8+eKv5zdyIPjyQRDYBysUoDv5huKe2eoxJu/MWS2Pql/ZtUGeD6Ozm3mCvh0vQ9ceagBkY6Ocm3du0ziAKP29Ri0mjg4DizVorbLzsh+EQH/s2Pi9MnjUZDlEmuLl2Xfp7/w4j/8u0N0tVR70VDFuGdKpTjFY3vS8EJrPtyMTM51x1D9rb8gIql8aR/rJw4YF+huxg1mv5n6+tGVqg5msbPmF12eJijP4lkmaRwIpLW5pJTtaDkUj7uOeu1mm4k+Dt5nh0/0jPHzrv6bcTCcbV7UjMHDoTXXqEpFAAJ66rHR7zdAJu+YKsnTIZyLmOpcowq7LL8G9qTvV0OSpyQWUIavRSgbDHFqEqRs+JU94jAzkq8nCY5MTd9m5sIv9InfdT3k+pwpsE/FKge8nghFLtbUrafGkzTky8SE2druvVcIvbfXMfLIKRUYjJgnWc0gQzF5J6pzXM7D2r/RG6JDzASqjlbURq6v9bhNerlOVdMujWKEEVcKWIzlbt4RkihRjM8AUqIZQOyicGQ+4yfIjAHw5viuABONYs3OIWULnFqJxdvS9rNKhfxSjIq9cfqyzevq2xrRoMXEonobh6M3bD2Vang8OAeVeD1OXWPERi4pepCYFS9RJ/Xa/UWxptsqSNuGcb3fAzQSmLpXLGdWRoKXvSe7EYgc0bGcLOjSTu5RURKo+EF9i4KT9EJauf6VXw5dTf/CCIJRXE1bWzXhSCFYntohYhX2ldOCDYpi/jFBC6Vtkw0ud3/xq8Nmhd5gUk+SpngByCZH3Pm3H+jvlbMpiqkDkm1v74hDX13Xhrcw2eWyuqKBVoRCCniUvwpYNbGvBfjC6Hcizv0Aybciwj+4nybt5EPoEUm6S6Gs7fG7QpPdvrzpAxX70MlmdkF/gwyuhbEeJhLK+WL7qAsN5CvHPzVbsIf90x+nGTtMJPgpxVr0tJMj+vprXV4WxutfARBiOnqe58MhA857sd+MzKBgKnoLOBRTiC3qc/0/ULwbG2HCCD7nmwzz7M4nUuMvo8rgS7z0BF68OClT8X3JwSXbL5Wg=="),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -129,39 +129,39 @@ func (s *ExportService) getScanValuesForTable(cols []string, types utils.DBSchem
|
||||
case "boolean", "bool":
|
||||
var x bool
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
res[i] = new(new(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
res[i] = new(x)
|
||||
}
|
||||
case "blob", "bytea", "jsonb":
|
||||
// Treat jsonb columns as binary too
|
||||
var x []byte
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
res[i] = new(new(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
res[i] = new(x)
|
||||
}
|
||||
case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
|
||||
var x datatype.DateTime
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
res[i] = new(new(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
res[i] = new(x)
|
||||
}
|
||||
case "integer", "int", "bigint":
|
||||
var x int64
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
res[i] = new(new(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
res[i] = new(x)
|
||||
}
|
||||
default:
|
||||
// Treat everything else as a string (including the "numeric" type)
|
||||
var x string
|
||||
if types[col].Nullable {
|
||||
res[i] = utils.Ptr(utils.Ptr(x))
|
||||
res[i] = new(new(x))
|
||||
} else {
|
||||
res[i] = utils.Ptr(x)
|
||||
res[i] = new(x)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"errors"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
@@ -22,6 +24,8 @@ import (
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
|
||||
|
||||
type GeoLiteService struct {
|
||||
httpClient *http.Client
|
||||
disableUpdater bool
|
||||
@@ -109,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()
|
||||
@@ -151,7 +159,24 @@ func (s *GeoLiteService) isDatabaseUpToDate() bool {
|
||||
|
||||
// extractDatabase extracts the database file from the tar.gz archive directly to the target location.
|
||||
func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
|
||||
gzr, err := gzip.NewReader(reader)
|
||||
// Check for gzip magic number
|
||||
buf := make([]byte, 2)
|
||||
_, err := io.ReadFull(reader, buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read magic number: %w", err)
|
||||
}
|
||||
|
||||
// Check if the file starts with the gzip magic number
|
||||
// Gosec returns false positive for "G602: slice index out of range"
|
||||
//nolint:gosec
|
||||
isGzip := buf[0] == 0x1f && buf[1] == 0x8b
|
||||
|
||||
if !isGzip {
|
||||
// If not gzip, assume it's a regular database file
|
||||
return s.writeDatabaseFile(io.MultiReader(bytes.NewReader(buf), reader))
|
||||
}
|
||||
|
||||
gzr, err := gzip.NewReader(io.MultiReader(bytes.NewReader(buf), reader))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
@@ -160,7 +185,6 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
|
||||
tarReader := tar.NewReader(gzr)
|
||||
|
||||
var totalSize int64
|
||||
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
|
||||
|
||||
// Iterate over the files in the tar archive
|
||||
for {
|
||||
@@ -222,3 +246,47 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
|
||||
|
||||
return errors.New("GeoLite2-City.mmdb not found in archive")
|
||||
}
|
||||
|
||||
func (s *GeoLiteService) writeDatabaseFile(reader io.Reader) error {
|
||||
baseDir := filepath.Dir(common.EnvConfig.GeoLiteDBPath)
|
||||
tmpFile, err := os.CreateTemp(baseDir, "geolite.*.mmdb.tmp")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temporary database file: %w", err)
|
||||
}
|
||||
defer tmpFile.Close()
|
||||
|
||||
// Limit the amount we read to maxTotalSize.
|
||||
// We read one extra byte to detect if the source is larger than the limit.
|
||||
limitReader := io.LimitReader(reader, maxTotalSize+1)
|
||||
|
||||
// Write the file contents directly to the temporary file
|
||||
written, err := io.Copy(tmpFile, limitReader)
|
||||
if err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return fmt.Errorf("failed to write database file: %w", err)
|
||||
}
|
||||
|
||||
if written > maxTotalSize {
|
||||
os.Remove(tmpFile.Name())
|
||||
return errors.New("total database size exceeds maximum allowed limit")
|
||||
}
|
||||
|
||||
// Validate the downloaded database file
|
||||
if db, err := maxminddb.Open(tmpFile.Name()); err == nil {
|
||||
db.Close()
|
||||
} else {
|
||||
os.Remove(tmpFile.Name())
|
||||
return fmt.Errorf("failed to open downloaded database file: %w", err)
|
||||
}
|
||||
|
||||
// Ensure atomic replacement of the old database file
|
||||
s.mutex.Lock()
|
||||
err = os.Rename(tmpFile.Name(), common.EnvConfig.GeoLiteDBPath)
|
||||
s.mutex.Unlock()
|
||||
|
||||
if err != nil {
|
||||
os.Remove(tmpFile.Name())
|
||||
return fmt.Errorf("failed to replace database file: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/lestrrat-go/jwx/v3/jwa"
|
||||
"github.com/lestrrat-go/jwx/v3/jwk"
|
||||
"github.com/lestrrat-go/jwx/v3/jwt"
|
||||
@@ -193,6 +194,7 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
|
||||
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
|
||||
IssuedAt(now).
|
||||
Issuer(s.envConfig.AppURL).
|
||||
JwtID(uuid.New().String()).
|
||||
Build()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to build token: %w", err)
|
||||
@@ -247,6 +249,7 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
|
||||
Expiration(now.Add(1 * time.Hour)).
|
||||
IssuedAt(now).
|
||||
Issuer(s.envConfig.AppURL).
|
||||
JwtID(uuid.New().String()).
|
||||
Build()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build token: %w", err)
|
||||
@@ -336,6 +339,7 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
|
||||
Expiration(now.Add(1 * time.Hour)).
|
||||
IssuedAt(now).
|
||||
Issuer(s.envConfig.AppURL).
|
||||
JwtID(uuid.New().String()).
|
||||
Build()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build token: %w", err)
|
||||
|
||||
@@ -20,13 +20,14 @@ import (
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
const testEncryptionKey = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
const uuidRegexPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||
|
||||
func newTestEnvConfig() *common.EnvConfigSchema {
|
||||
return &common.EnvConfigSchema{
|
||||
AppURL: "https://test.example.com",
|
||||
@@ -303,7 +304,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user123"},
|
||||
Email: utils.Ptr("user@example.com"),
|
||||
Email: new("user@example.com"),
|
||||
IsAdmin: false,
|
||||
}
|
||||
|
||||
@@ -323,6 +324,9 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
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")
|
||||
jwtID, ok := claims.JwtID()
|
||||
_ = assert.True(t, ok, "JWT ID not found in token") &&
|
||||
assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
|
||||
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
expiration, ok := claims.Expiration()
|
||||
@@ -336,7 +340,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
|
||||
adminUser := model.User{
|
||||
Base: model.Base{ID: "admin123"},
|
||||
Email: utils.Ptr("admin@example.com"),
|
||||
Email: new("admin@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -388,7 +392,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "eddsauser123"},
|
||||
Email: utils.Ptr("eddsauser@example.com"),
|
||||
Email: new("eddsauser@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -425,7 +429,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "ecdsauser123"},
|
||||
Email: utils.Ptr("ecdsauser@example.com"),
|
||||
Email: new("ecdsauser@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -462,7 +466,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "rsauser123"},
|
||||
Email: utils.Ptr("rsauser@example.com"),
|
||||
Email: new("rsauser@example.com"),
|
||||
IsAdmin: true,
|
||||
}
|
||||
|
||||
@@ -497,7 +501,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "user123",
|
||||
"name": "Test User",
|
||||
"email": "user@example.com",
|
||||
@@ -520,6 +524,9 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
jwtID, ok := claims.JwtID()
|
||||
_ = assert.True(t, ok, "JWT ID not found in token") &&
|
||||
assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
|
||||
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
expiration, ok := claims.Expiration()
|
||||
@@ -531,7 +538,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
t.Run("can accept expired tokens if told so", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "user123",
|
||||
"name": "Test User",
|
||||
"email": "user@example.com",
|
||||
@@ -579,7 +586,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "user456",
|
||||
"name": "Another User",
|
||||
}
|
||||
@@ -604,7 +611,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
t.Run("fails verification with incorrect issuer", func(t *testing.T) {
|
||||
service, _, _ := setupJwtService(t, mockConfig)
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "user789",
|
||||
}
|
||||
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
|
||||
@@ -626,7 +633,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "eddsauser456",
|
||||
"name": "EdDSA User",
|
||||
"email": "eddsauser@example.com",
|
||||
@@ -664,7 +671,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "ecdsauser456",
|
||||
"email": "ecdsauser@example.com",
|
||||
}
|
||||
@@ -701,7 +708,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
|
||||
|
||||
userClaims := map[string]interface{}{
|
||||
userClaims := map[string]any{
|
||||
"sub": "rsauser456",
|
||||
"name": "RSA User",
|
||||
"email": "rsauser@example.com",
|
||||
@@ -734,7 +741,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "user123"},
|
||||
Email: utils.Ptr("user@example.com"),
|
||||
Email: new("user@example.com"),
|
||||
}
|
||||
const clientID = "test-client-123"
|
||||
|
||||
@@ -754,6 +761,9 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
issuer, ok := claims.Issuer()
|
||||
_ = assert.True(t, ok, "Issuer not found in token") &&
|
||||
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL")
|
||||
jwtID, ok := claims.JwtID()
|
||||
_ = assert.True(t, ok, "JWT ID not found in token") &&
|
||||
assert.Regexp(t, uuidRegexPattern, jwtID, "JWT ID is not a UUID")
|
||||
|
||||
expectedExp := time.Now().Add(1 * time.Hour)
|
||||
expiration, ok := claims.Expiration()
|
||||
@@ -814,7 +824,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "eddsauser789"},
|
||||
Email: utils.Ptr("eddsaoauth@example.com"),
|
||||
Email: new("eddsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "eddsa-oauth-client"
|
||||
|
||||
@@ -851,7 +861,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "ecdsauser789"},
|
||||
Email: utils.Ptr("ecdsaoauth@example.com"),
|
||||
Email: new("ecdsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "ecdsa-oauth-client"
|
||||
|
||||
@@ -888,7 +898,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
|
||||
|
||||
user := model.User{
|
||||
Base: model.Base{ID: "rsauser789"},
|
||||
Email: utils.Ptr("rsaoauth@example.com"),
|
||||
Email: new("rsaoauth@example.com"),
|
||||
}
|
||||
const clientID = "rsa-oauth-client"
|
||||
|
||||
|
||||
@@ -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,201 @@ 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)
|
||||
}
|
||||
|
||||
// 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 +572,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 +680,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 {
|
||||
@@ -529,7 +712,7 @@ func getDNProperty(property string, str string) string {
|
||||
// First we split at the comma
|
||||
property = strings.ToLower(property)
|
||||
l := len(property) + 1
|
||||
for _, v := range strings.Split(str, ",") {
|
||||
for v := range strings.SplitSeq(str, ",") {
|
||||
v = strings.TrimSpace(v)
|
||||
if len(v) > l && strings.ToLower(v)[0:l] == property+"=" {
|
||||
return v[l:]
|
||||
|
||||
@@ -1,9 +1,368 @@
|
||||
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))
|
||||
}
|
||||
|
||||
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)":
|
||||
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 +423,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 +505,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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +404,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{}
|
||||
}
|
||||
|
||||
@@ -731,7 +731,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
|
||||
Base: model.Base{
|
||||
ID: input.ID,
|
||||
},
|
||||
CreatedByID: utils.Ptr(userID),
|
||||
CreatedByID: new(userID),
|
||||
}
|
||||
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
|
||||
|
||||
@@ -1644,34 +1644,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 +1671,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,36 +1768,20 @@ 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) {
|
||||
tx := s.db.Begin()
|
||||
defer func() {
|
||||
|
||||
@@ -229,6 +229,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 +467,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 +526,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,
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
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
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -33,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
|
||||
@@ -148,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))
|
||||
@@ -167,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))
|
||||
}
|
||||
}
|
||||
@@ -209,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)),
|
||||
@@ -239,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 = utils.Ptr(datatype.DateTime(time.Now()))
|
||||
if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
|
||||
provider.LastSyncedAt = new(datatype.DateTime(time.Now()))
|
||||
err = s.db.WithContext(ctx).Save(&provider).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -272,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 {
|
||||
@@ -433,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)
|
||||
@@ -788,10 +781,8 @@ func ensureScimStatus(
|
||||
resp *http.Response,
|
||||
provider model.ScimServiceProvider,
|
||||
allowedStatuses ...int) error {
|
||||
for _, status := range allowedStatuses {
|
||||
if resp.StatusCode == status {
|
||||
return nil
|
||||
}
|
||||
if slices.Contains(allowedStatuses, resp.StatusCode) {
|
||||
return nil
|
||||
}
|
||||
|
||||
body := readScimErrorBody(resp.Body)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -162,7 +168,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
|
||||
|
||||
group.Name = input.Name
|
||||
group.FriendlyName = input.FriendlyName
|
||||
group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||
group.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -228,7 +237,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
|
||||
}
|
||||
|
||||
// Save the updated group
|
||||
group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||
group.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -435,7 +441,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
|
||||
}
|
||||
}
|
||||
|
||||
user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||
user.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
|
||||
err = tx.
|
||||
WithContext(ctx).
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -501,9 +510,9 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
|
||||
}
|
||||
|
||||
// Update the UpdatedAt field for all affected groups
|
||||
now := time.Now()
|
||||
now := datatype.DateTime(time.Now())
|
||||
for _, group := range groups {
|
||||
group.UpdatedAt = utils.Ptr(datatype.DateTime(now))
|
||||
group.UpdatedAt = &now
|
||||
err = tx.WithContext(ctx).Save(&group).Error
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -636,7 +651,7 @@ func (s *UserService) VerifyEmail(ctx context.Context, userID string, token stri
|
||||
}
|
||||
|
||||
user.EmailVerified = true
|
||||
user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
|
||||
user.UpdatedAt = new(datatype.DateTime(time.Now()))
|
||||
err = tx.WithContext(ctx).Save(&user).Error
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/utils"
|
||||
)
|
||||
|
||||
@@ -31,6 +32,10 @@ func NewVersionService(httpClient *http.Client) *VersionService {
|
||||
}
|
||||
|
||||
func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) {
|
||||
if common.EnvConfig.VersionCheckDisabled {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
version, err := s.cache.GetOrFetch(ctx, func(ctx context.Context) (string, error) {
|
||||
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -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,17 +586,24 @@ 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 without brackets in input",
|
||||
urls: []string{"http://[::1]/callback"},
|
||||
inputCallbackURL: "http://::1:8080/callback",
|
||||
expectedURL: "http://::1:8080/callback",
|
||||
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",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
@@ -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"},
|
||||
@@ -462,6 +662,13 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
|
||||
expectedURL: "http://127.0.0.1:8080/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard matches IPv6 loopback",
|
||||
urls: []string{"*"},
|
||||
inputCallbackURL: "http://[::1]:8080/callback",
|
||||
expectedURL: "http://[::1]:8080/callback",
|
||||
expectMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -477,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
|
||||
@@ -546,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")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package utils
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -21,6 +22,27 @@ func BearerAuth(r *http.Request) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// OAuthClientBasicAuth returns the OAuth client ID and secret provided in the request's
|
||||
// Authorization header, if present. See RFC 6749, Section 2.3.
|
||||
func OAuthClientBasicAuth(r *http.Request) (clientID, clientSecret string, ok bool) {
|
||||
clientID, clientSecret, ok = r.BasicAuth()
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
clientID, err := url.QueryUnescape(clientID)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
clientSecret, err = url.QueryUnescape(clientSecret)
|
||||
if err != nil {
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
return clientID, clientSecret, true
|
||||
}
|
||||
|
||||
// SetCacheControlHeader sets the Cache-Control header for the response.
|
||||
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
|
||||
_, ok := ctx.GetQuery("skipCache")
|
||||
|
||||
@@ -63,3 +63,62 @@ func TestBearerAuth(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOAuthClientBasicAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
authHeader string
|
||||
expectedClientID string
|
||||
expectedClientSecret string
|
||||
expectedOk bool
|
||||
}{
|
||||
{
|
||||
name: "Valid client ID and secret in header (example from RFC 6749)",
|
||||
authHeader: "Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3",
|
||||
expectedClientID: "s6BhdRkqt3",
|
||||
expectedClientSecret: "7Fjfp0ZBr1KtDRbnfVdmIw",
|
||||
expectedOk: true,
|
||||
},
|
||||
{
|
||||
name: "Valid client ID and secret in header (escaped values)",
|
||||
authHeader: "Basic ZTUwOTcyYmQtNmUzMi00OTU3LWJhZmMtMzU0MTU3ZjI1NDViOislMjUlMjYlMkIlQzIlQTMlRTIlODIlQUM=",
|
||||
expectedClientID: "e50972bd-6e32-4957-bafc-354157f2545b",
|
||||
// This is the example string from RFC 6749, Appendix B.
|
||||
expectedClientSecret: " %&+£€",
|
||||
expectedOk: true,
|
||||
},
|
||||
{
|
||||
name: "Empty auth header",
|
||||
authHeader: "",
|
||||
expectedClientID: "",
|
||||
expectedClientSecret: "",
|
||||
expectedOk: false,
|
||||
},
|
||||
{
|
||||
name: "Basic prefix only",
|
||||
authHeader: "Basic ",
|
||||
expectedClientID: "",
|
||||
expectedClientSecret: "",
|
||||
expectedOk: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, "http://example.com", nil)
|
||||
require.NoError(t, err, "Failed to create request")
|
||||
|
||||
if tt.authHeader != "" {
|
||||
req.Header.Set("Authorization", tt.authHeader)
|
||||
}
|
||||
|
||||
clientId, clientSecret, ok := OAuthClientBasicAuth(req)
|
||||
|
||||
assert.Equal(t, tt.expectedOk, ok)
|
||||
|
||||
if tt.expectedOk {
|
||||
assert.Equal(t, tt.expectedClientID, clientId)
|
||||
assert.Equal(t, tt.expectedClientSecret, clientSecret)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,9 @@ func listContainsIP(ipNets []*net.IPNet, ip net.IP) bool {
|
||||
|
||||
func loadLocalIPv6Ranges() {
|
||||
localIPv6Ranges = nil
|
||||
ranges := strings.Split(common.EnvConfig.LocalIPv6Ranges, ",")
|
||||
ranges := strings.SplitSeq(common.EnvConfig.LocalIPv6Ranges, ",")
|
||||
|
||||
for _, rangeStr := range ranges {
|
||||
for rangeStr := range ranges {
|
||||
rangeStr = strings.TrimSpace(rangeStr)
|
||||
if rangeStr == "" {
|
||||
continue
|
||||
|
||||
@@ -42,7 +42,7 @@ func (d *JSONDuration) UnmarshalJSON(b []byte) error {
|
||||
}
|
||||
}
|
||||
|
||||
func UnmarshalJSONFromDatabase(data interface{}, value any) error {
|
||||
func UnmarshalJSONFromDatabase(data any, value any) error {
|
||||
switch v := value.(type) {
|
||||
case []byte:
|
||||
return json.Unmarshal(v, data)
|
||||
|
||||
@@ -43,7 +43,7 @@ func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOp
|
||||
return listRequestOptions
|
||||
}
|
||||
|
||||
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any) (PaginationResponse, error) {
|
||||
meta := extractModelMetadata(result)
|
||||
|
||||
query = applyFilters(params.Filters, query, meta)
|
||||
@@ -52,7 +52,7 @@ func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result int
|
||||
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
|
||||
}
|
||||
|
||||
func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
|
||||
func Paginate(page int, pageSize int, query *gorm.DB, result any) (PaginationResponse, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
@@ -117,8 +117,8 @@ func parseNestedFilters(ctx *gin.Context) map[string][]any {
|
||||
// Keys can be "filters[field]" or "filters[field][0]"
|
||||
raw := strings.TrimPrefix(key, "filters[")
|
||||
// Take everything up to the first closing bracket
|
||||
if idx := strings.IndexByte(raw, ']'); idx != -1 {
|
||||
field := raw[:idx]
|
||||
if before, _, ok := strings.Cut(raw, "]"); ok {
|
||||
field := before
|
||||
for _, v := range values {
|
||||
result[field] = append(result[field], ConvertStringToType(v))
|
||||
}
|
||||
@@ -165,12 +165,12 @@ func applySorting(sortColumn string, sortDirection string, query *gorm.DB, meta
|
||||
}
|
||||
|
||||
// extractModelMetadata extracts FieldMeta from the model struct using reflection
|
||||
func extractModelMetadata(model interface{}) map[string]FieldMeta {
|
||||
func extractModelMetadata(model any) map[string]FieldMeta {
|
||||
meta := make(map[string]FieldMeta)
|
||||
|
||||
// Unwrap pointers and slices to get the element struct type
|
||||
t := reflect.TypeOf(model)
|
||||
for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
|
||||
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice {
|
||||
t = t.Elem()
|
||||
if t == nil {
|
||||
return meta
|
||||
@@ -180,8 +180,7 @@ func extractModelMetadata(model interface{}) map[string]FieldMeta {
|
||||
// recursive parser that merges fields from embedded structs
|
||||
var parseStruct func(reflect.Type)
|
||||
parseStruct = func(st reflect.Type) {
|
||||
for i := 0; i < st.NumField(); i++ {
|
||||
field := st.Field(i)
|
||||
for field := range st.Fields() {
|
||||
ft := field.Type
|
||||
|
||||
// If the field is an embedded/anonymous struct, recurse into it
|
||||
|
||||
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
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
package utils
|
||||
|
||||
// Ptr returns a pointer to the given value.
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
// PtrOrNil returns a pointer to v if v is not the zero value of its type,
|
||||
// otherwise it returns nil.
|
||||
func PtrOrNil[T comparable](v T) *T {
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// This file contains code adapted from https://github.com/samber/slog-multi
|
||||
// Source: https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/pipe.go
|
||||
// Copyright (C) 2023 Samuel Berthe
|
||||
// License: MIT (https://github.com/samber/slog-multi/blob/ced84707f45ec9848138349ed58de178eedaa6f2/LICENSE)
|
||||
|
||||
// LogFanoutHandler is a slog.Handler that sends logs to multiple destinations
|
||||
type LogFanoutHandler []slog.Handler
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) Enabled(ctx context.Context, l slog.Level) bool {
|
||||
for i := range h {
|
||||
if h[i].Enabled(ctx, l) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
errs := make([]error, 0)
|
||||
for i := range h {
|
||||
if h[i].Enabled(ctx, r.Level) {
|
||||
err := try(func() error {
|
||||
return h[i].Handle(ctx, r.Clone())
|
||||
})
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
res := make(LogFanoutHandler, len(h))
|
||||
for i, v := range h {
|
||||
res[i] = v.WithAttrs(slices.Clone(attrs))
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// Implements slog.Handler
|
||||
func (h LogFanoutHandler) WithGroup(name string) slog.Handler {
|
||||
// https://cs.opensource.google/go/x/exp/+/46b07846:slog/handler.go;l=247
|
||||
if name == "" {
|
||||
return h
|
||||
}
|
||||
|
||||
res := make(LogFanoutHandler, len(h))
|
||||
for i, v := range h {
|
||||
res[i] = v.WithGroup(name)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func try(callback func() error) (err error) {
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r != nil {
|
||||
if e, ok := r.(error); ok {
|
||||
err = e
|
||||
} else {
|
||||
err = fmt.Errorf("unexpected error: %+v", r)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
err = callback()
|
||||
|
||||
return
|
||||
}
|
||||
@@ -70,11 +70,6 @@ func GetHostnameFromURL(rawURL string) string {
|
||||
return parsedURL.Hostname()
|
||||
}
|
||||
|
||||
// StringPointer creates a string pointer from a string value
|
||||
func StringPointer(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
func CapitalizeFirstLetter(str string) string {
|
||||
if str == "" {
|
||||
return ""
|
||||
|
||||
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 @@
|
||||
-- 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;
|
||||
@@ -4,7 +4,7 @@
|
||||
ARG BUILD_TAGS=""
|
||||
|
||||
# Stage 1: Build Frontend
|
||||
FROM node:22-alpine AS frontend-builder
|
||||
FROM node:24-alpine AS frontend-builder
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /build
|
||||
@@ -18,7 +18,7 @@ COPY ./frontend ./frontend/
|
||||
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
|
||||
|
||||
# Stage 2: Build Backend
|
||||
FROM golang:1.25-alpine AS backend-builder
|
||||
FROM golang:1.26-alpine AS backend-builder
|
||||
ARG BUILD_TAGS
|
||||
WORKDIR /build
|
||||
COPY ./backend/go.mod ./backend/go.sum ./
|
||||
|
||||
@@ -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"}}',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,4 +5,5 @@ yarn.lock
|
||||
|
||||
# Compiled files
|
||||
.svelte-kit/
|
||||
build/
|
||||
build/
|
||||
src/lib/paraglide/messages
|
||||
@@ -365,7 +365,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",
|
||||
|
||||
@@ -356,7 +356,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 +365,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 +446,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",
|
||||
|
||||
@@ -356,8 +356,8 @@
|
||||
"login_code_email_success": "Der Login-Code wurde an den Benutzer gesendet.",
|
||||
"send_email": "E-Mail senden",
|
||||
"show_code": "Code anzeigen",
|
||||
"callback_url_description": "Die URL(s) von deinem Kunden. Wenn du das Feld leer lässt, werden sie automatisch hinzugefügt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Platzhalter</link> werden unterstützt.",
|
||||
"logout_callback_url_description": "Von deinem Kunden angegebene URL(s) zum Abmelden. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Platzhalter</link> werden unterstützt.",
|
||||
"callback_url_description": "Die URL(s) von deinem Client. Wenn du das Feld leer lässt, werden sie automatisch hinzugefügt. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Platzhalter</link> werden unterstützt.",
|
||||
"logout_callback_url_description": "Von deinem Client angegebene URL(s) zum Abmelden. <link href='https://pocket-id.org/docs/advanced/callback-url-wildcards'>Platzhalter</link> werden unterstützt.",
|
||||
"api_key_expiration": "API-Schlüssel-Ablauf",
|
||||
"send_an_email_to_the_user_when_their_api_key_is_about_to_expire": "Sende eine E-Mail an den Benutzer, wenn sein API-Schlüssel ablaufen wird.",
|
||||
"authorize_device": "Gerät autorisieren",
|
||||
@@ -365,7 +365,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",
|
||||
@@ -397,21 +397,21 @@
|
||||
"color_value": "Farbwert",
|
||||
"apply": "Übernehmen",
|
||||
"signup_token": "Anmeldungstoken",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Erstell ein Anmeldetoken, damit sich neue Benutzer registrieren können.",
|
||||
"create_a_signup_token_to_allow_new_user_registration": "Erstell einen Registrierungstoken, damit sich neue Benutzer registrieren können.",
|
||||
"usage_limit": "Nutzungsbeschränkung",
|
||||
"number_of_times_token_can_be_used": "Wie oft der Anmeldetoken benutzt werden kann.",
|
||||
"number_of_times_token_can_be_used": "Wie oft der Registrierungstoken benutzt werden kann.",
|
||||
"expires": "Läuft ab",
|
||||
"signup": "Anmelden",
|
||||
"signup": "Registrieren",
|
||||
"user_creation": "Benutzererstellung",
|
||||
"configure_user_creation": "Verwalte die Einstellungen für die Benutzererstellung, einschließlich der Anmeldemethoden und Standardberechtigungen für neue Benutzer.",
|
||||
"user_creation_groups_description": "Weise diese Gruppen neuen Benutzern bei der Anmeldung automatisch zu.",
|
||||
"user_creation_claims_description": "Weise diese benutzerdefinierten Ansprüche neuen Benutzern bei der Anmeldung automatisch zu.",
|
||||
"configure_user_creation": "Verwalte die Einstellungen für die Benutzererstellung, einschließlich der Registrierungsmethoden und Standardberechtigungen für neue Benutzer.",
|
||||
"user_creation_groups_description": "Weise diese Gruppen neuen Benutzern bei der Registrierung automatisch zu.",
|
||||
"user_creation_claims_description": "Weise diese benutzerdefinierten Ansprüche neuen Benutzern bei der Registrierung automatisch zu.",
|
||||
"user_creation_updated_successfully": "Einstellungen für die Benutzererstellung erfolgreich aktualisiert.",
|
||||
"signup_disabled_description": "Benutzeranmeldungen sind komplett deaktiviert. Nur Admins können neue Benutzerkonten erstellen.",
|
||||
"signup_requires_valid_token": "Zum Erstellen eines Kontos brauchst du einen gültigen Anmeldetoken.",
|
||||
"signup_requires_valid_token": "Zum Erstellen eines Kontos brauchst du einen gültigen Registrierungstoken.",
|
||||
"validating_signup_token": "Anmeldungstoken bestätigen",
|
||||
"go_to_login": "Zum Login gehen",
|
||||
"signup_to_appname": "Melde dich bei „ {appName}“ an",
|
||||
"signup_to_appname": "Registriere dich bei „ {appName}“",
|
||||
"create_your_account_to_get_started": "Erstell dein Konto, um loszulegen.",
|
||||
"initial_account_creation_description": "Erstell dein Konto, um loszulegen. Du kannst später einen Passkey einrichten.",
|
||||
"setup_your_passkey": "Passkey einrichten",
|
||||
@@ -419,12 +419,12 @@
|
||||
"skip_for_now": "Jetzt überspringen",
|
||||
"account_created": "Konto erstellt",
|
||||
"enable_user_signups": "Benutzeranmeldungen aktivieren",
|
||||
"enable_user_signups_description": "Entscheide, wie sich Leute für neue Konten in Pocket ID anmelden können.",
|
||||
"enable_user_signups_description": "Entscheide, wie sich Leute für neue Konten in Pocket ID registrieren können.",
|
||||
"user_signups_are_disabled": "Benutzeranmeldungen sind im Moment deaktiviert.",
|
||||
"create_signup_token": "Anmeldungstoken erstellen",
|
||||
"view_active_signup_tokens": "Aktive Anmeldetoken anzeigen",
|
||||
"view_active_signup_tokens": "Aktive Registrierungstoken anzeigen",
|
||||
"manage_signup_tokens": "Anmeldungstoken verwalten",
|
||||
"view_and_manage_active_signup_tokens": "Aktive Anmeldetoken anzeigen und verwalten.",
|
||||
"view_and_manage_active_signup_tokens": "Aktive Registrierungstoken anzeigen und verwalten.",
|
||||
"signup_token_deleted_successfully": "Anmeldungstoken erfolgreich gelöscht.",
|
||||
"expired": "Abgelaufen",
|
||||
"used_up": "Aufgebraucht",
|
||||
@@ -434,9 +434,9 @@
|
||||
"token": "Token",
|
||||
"loading": "Laden",
|
||||
"delete_signup_token": "Anmeldungstoken löschen",
|
||||
"are_you_sure_you_want_to_delete_this_signup_token": "Willst du diesen Anmeldetoken wirklich löschen? Das kannst du nicht rückgängig machen.",
|
||||
"signup_with_token": "Mit Token anmelden",
|
||||
"signup_with_token_description": "Benutzer können sich nur mit einem gültigen Anmeldetoken anmelden, das von einem Administrator erstellt wurde.",
|
||||
"are_you_sure_you_want_to_delete_this_signup_token": "Willst du diesen Registrierungstoken wirklich löschen? Das kannst du nicht rückgängig machen.",
|
||||
"signup_with_token": "Mit Token registrieren",
|
||||
"signup_with_token_description": "Benutzer können sich nur mit einem gültigen Registrierungstoken anmelden, das von einem Administrator erstellt wurde.",
|
||||
"signup_open": "Anmeldung offen",
|
||||
"signup_open_description": "Jeder kann ohne Einschränkungen ein neues Konto erstellen.",
|
||||
"of": "von",
|
||||
@@ -477,7 +477,7 @@
|
||||
"light": "Hell",
|
||||
"dark": "Dunkel",
|
||||
"system": "System",
|
||||
"signup_token_user_groups_description": "Weise diese Gruppen automatisch den Leuten zu, die sich mit diesem Token anmelden.",
|
||||
"signup_token_user_groups_description": "Weise diese Gruppen automatisch den Leuten zu, die sich mit diesem Token registrieren.",
|
||||
"allowed_oidc_clients": "Zugelassene OIDC-Clients",
|
||||
"allowed_oidc_clients_description": "Wähle die OIDC-Clients aus, bei denen sich Mitglieder dieser Benutzergruppe anmelden dürfen.",
|
||||
"unrestrict_oidc_client": "Uneingeschränkt {clientName}",
|
||||
@@ -513,7 +513,7 @@
|
||||
"email_verification_warning": "Bestätige deine E-Mail-Adresse",
|
||||
"email_verification_warning_description": "Deine E-Mail-Adresse ist noch nicht bestätigt. Bitte bestätige sie so schnell wie möglich.",
|
||||
"email_verification": "E-Mail-Bestätigung",
|
||||
"email_verification_description": "Schick den Nutzern eine Bestätigungs-E-Mail, wenn sie sich anmelden oder ihre E-Mail-Adresse ändern.",
|
||||
"email_verification_description": "Schick den Nutzern eine Bestätigungs-E-Mail, wenn sie sich registrieren oder ihre E-Mail-Adresse ändern.",
|
||||
"email_verification_success_title": "E-Mail erfolgreich bestätigt",
|
||||
"email_verification_success_description": "Deine E-Mail-Adresse wurde erfolgreich bestätigt.",
|
||||
"email_verification_error_title": "E-Mail-Verifizierung ist schiefgegangen",
|
||||
|
||||
@@ -365,7 +365,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",
|
||||
|
||||
@@ -365,7 +365,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",
|
||||
|
||||
525
frontend/messages/et.json
Normal file
525
frontend/messages/et.json
Normal file
@@ -0,0 +1,525 @@
|
||||
{
|
||||
"$schema": "https://inlang.com/schema/inlang-message-format",
|
||||
"my_account": "Minu konto",
|
||||
"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",
|
||||
"unknown": "Unknown",
|
||||
"account_details_updated_successfully": "Account details updated successfully",
|
||||
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
|
||||
"account_settings": "Account Settings",
|
||||
"passkey_missing": "Passkey missing",
|
||||
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
|
||||
"single_passkey_configured": "Single Passkey Configured",
|
||||
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
|
||||
"account_details": "Account Details",
|
||||
"passkeys": "Passkeys",
|
||||
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
|
||||
"add_passkey": "Add Passkey",
|
||||
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
|
||||
"create": "Create",
|
||||
"first_name": "First name",
|
||||
"last_name": "Last name",
|
||||
"username": "Username",
|
||||
"save": "Save",
|
||||
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
|
||||
"username_must_start_with": "Username must start with an alphanumeric character",
|
||||
"username_must_end_with": "Username must end with an alphanumeric character",
|
||||
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
|
||||
"or_visit": "or visit",
|
||||
"added_on": "Added on",
|
||||
"rename": "Rename",
|
||||
"delete": "Delete",
|
||||
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
|
||||
"passkey_deleted_successfully": "Passkey deleted successfully",
|
||||
"delete_passkey_name": "Delete {passkeyName}",
|
||||
"passkey_name_updated_successfully": "Passkey name updated successfully",
|
||||
"name_passkey": "Name Passkey",
|
||||
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
|
||||
"create_api_key": "Create API Key",
|
||||
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access to the <link href='https://pocket-id.org/docs/api'>Pocket ID API</link>.",
|
||||
"add_api_key": "Add API Key",
|
||||
"manage_api_keys": "Manage API Keys",
|
||||
"api_key_created": "API Key Created",
|
||||
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
|
||||
"description": "Description",
|
||||
"api_key": "API Key",
|
||||
"close": "Close",
|
||||
"name_to_identify_this_api_key": "Name to identify this API key.",
|
||||
"expires_at": "Expires At",
|
||||
"when_this_api_key_will_expire": "When this API key will expire.",
|
||||
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
|
||||
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
|
||||
"revoke_api_key": "Revoke API Key",
|
||||
"never": "Never",
|
||||
"revoke": "Revoke",
|
||||
"api_key_revoked_successfully": "API key revoked successfully",
|
||||
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
|
||||
"last_used": "Last Used",
|
||||
"actions": "Actions",
|
||||
"images_updated_successfully": "Images updated successfully. It may take a few minutes to update.",
|
||||
"general": "General",
|
||||
"configure_smtp_to_send_emails": "Enable email notifications to alert users when a login is detected from a new device or location.",
|
||||
"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": "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",
|
||||
"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."
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user