Compare commits

..

7 Commits

Author SHA1 Message Date
Elias Schneider
0d40d30d87 feat: add environment variable to disable built-in rate limiting 2026-01-11 12:34:26 +01:00
Elias Schneider
d318b02ea0 add e2e tests 2026-01-11 00:14:25 +01:00
Elias Schneider
dd8e4dec6c include expiration in email 2026-01-10 23:57:46 +01:00
Elias Schneider
ca4e332964 fix e2e tests 2026-01-10 23:56:31 +01:00
Elias Schneider
20ee00df49 pr feedback 2026-01-10 23:53:38 +01:00
Elias Schneider
0a49c8b699 mark ldap emails as verified 2026-01-10 23:14:08 +01:00
Elias Schneider
7d71191902 feat: add support for email verification 2026-01-10 23:11:54 +01:00
158 changed files with 2520 additions and 7649 deletions

View File

@@ -2,9 +2,7 @@
"name": "pocket-id", "name": "pocket-id",
"image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm", "image": "mcr.microsoft.com/devcontainers/typescript-node:1-22-bookworm",
"features": { "features": {
"ghcr.io/devcontainers/features/go:1": { "ghcr.io/devcontainers/features/go:1": {}
"version": "1.26"
}
}, },
"customizations": { "customizations": {
"vscode": { "vscode": {

View File

@@ -32,9 +32,9 @@ jobs:
go-version-file: backend/go.mod go-version-file: backend/go.mod
- name: Run Golangci-lint - name: Run Golangci-lint
uses: golangci/golangci-lint-action@v9.0.0 uses: golangci/golangci-lint-action@v8.0.0
with: with:
version: v2.9.0 version: v2.4.0
args: --build-tags=exclude_frontend args: --build-tags=exclude_frontend
working-directory: backend working-directory: backend
only-new-issues: ${{ github.event_name == 'pull_request' }} only-new-issues: ${{ github.event_name == 'pull_request' }}

View File

@@ -27,7 +27,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 24 node-version: 22
- name: Setup Go - name: Setup Go
uses: actions/setup-go@v6 uses: actions/setup-go@v6

View File

@@ -78,7 +78,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 24 node-version: 22
- name: Cache Playwright Browsers - name: Cache Playwright Browsers
uses: actions/cache@v4 uses: actions/cache@v4

View File

@@ -1,106 +0,0 @@
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

View File

@@ -21,7 +21,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 24 node-version: 22
- uses: actions/setup-go@v6 - uses: actions/setup-go@v6
with: with:
go-version-file: "backend/go.mod" go-version-file: "backend/go.mod"

View File

@@ -42,7 +42,7 @@ jobs:
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v5 uses: actions/setup-node@v5
with: with:
node-version: 24 node-version: 22
- name: Install dependencies - name: Install dependencies
run: pnpm --filter pocket-id-frontend install --frozen-lockfile run: pnpm --filter pocket-id-frontend install --frozen-lockfile

View File

@@ -1 +1 @@
2.5.0 2.1.0

View File

@@ -1,131 +1,3 @@
## 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
- allow changing "require email address" if no SMTP credentials present ([8c68b08](https://github.com/pocket-id/pocket-id/commit/8c68b08c12ba371deda61662e3d048d63d07c56f) by @stonith404)
- data import from sqlite to postgres fails because of wrong datatype ([1a032a8](https://github.com/pocket-id/pocket-id/commit/1a032a812ef78b250a898d14bec73a8ef7a7859a) by @stonith404)
- user can't update account if email is empty ([5828fa5](https://github.com/pocket-id/pocket-id/commit/5828fa57791314594625d52475733dce23cc2fcc) by @stonith404)
- login codes sent by an admin incorrectly requires a device token ([03f9be0](https://github.com/pocket-id/pocket-id/commit/03f9be0d125732e02a8e2c5390d9e6d0c74ce957) by @stonith404)
- allow exchanging logic code if already authenticated ([0e2cdc3](https://github.com/pocket-id/pocket-id/commit/0e2cdc393e34276bb3b8ea318cdc7261de3f2dec) by @stonith404)
- db version downgrades don't downgrade db schema ([4df4bcb](https://github.com/pocket-id/pocket-id/commit/4df4bcb6451b4bf88093e04f3222c8737f2c7be3) by @stonith404)
- use user specific email verified claim instead of global one ([2a11c3e](https://github.com/pocket-id/pocket-id/commit/2a11c3e60942d45c2e5b422d99945bce65a622a2) by @stonith404)
### Features
- add CLI command for encryption key rotation ([#1209](https://github.com/pocket-id/pocket-id/pull/1209) by @stonith404)
- improve passkey error messages ([2f25861](https://github.com/pocket-id/pocket-id/commit/2f25861d15aefa868042e70d3e21b7b38a6ae679) by @stonith404)
- make home page URL configurable ([#1215](https://github.com/pocket-id/pocket-id/pull/1215) by @stonith404)
- add option to renew API key ([#1214](https://github.com/pocket-id/pocket-id/pull/1214) by @stonith404)
- add support for email verification ([#1223](https://github.com/pocket-id/pocket-id/pull/1223) by @stonith404)
- add environment variable to disable built-in rate limiting ([9ca3d33](https://github.com/pocket-id/pocket-id/commit/9ca3d33c8897cf49a871783058205bb180529cd2) by @stonith404)
- add static api key env variable ([#1229](https://github.com/pocket-id/pocket-id/pull/1229) by @stonith404)
**Full Changelog**: https://github.com/pocket-id/pocket-id/compare/v2.1.0...v2.2.0
## v2.1.0 ## v2.1.0
### Bug Fixes ### Bug Fixes

View File

@@ -21,6 +21,7 @@ Before you submit the pull request for review please ensure that
``` ```
Where `TYPE` can be: Where `TYPE` can be:
- **feat** - is a new feature - **feat** - is a new feature
- **doc** - documentation only changes - **doc** - documentation only changes
- **fix** - a bug fix - **fix** - a bug fix
@@ -50,8 +51,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: If you don't use Dev Containers, you need to install the following tools manually:
- [Node.js](https://nodejs.org/en/download/) >= 24 - [Node.js](https://nodejs.org/en/download/) >= 22
- [Go](https://golang.org/doc/install) >= 1.26 - [Go](https://golang.org/doc/install) >= 1.25
- [Git](https://git-scm.com/downloads) - [Git](https://git-scm.com/downloads)
### 2. Setup ### 2. Setup

View File

@@ -4,6 +4,6 @@ package frontend
import "github.com/gin-gonic/gin" import "github.com/gin-gonic/gin"
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error { func RegisterFrontend(router *gin.Engine) error {
return ErrFrontendNotIncluded return ErrFrontendNotIncluded
} }

View File

@@ -8,10 +8,8 @@ import (
"fmt" "fmt"
"io" "io"
"io/fs" "io/fs"
"mime"
"net/http" "net/http"
"os" "os"
"path"
"strings" "strings"
"time" "time"
@@ -54,23 +52,16 @@ func init() {
} }
} }
func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) error { func RegisterFrontend(router *gin.Engine) error {
distFS, err := fs.Sub(frontendFS, "dist") distFS, err := fs.Sub(frontendFS, "dist")
if err != nil { if err != nil {
return fmt.Errorf("failed to create sub FS: %w", err) return fmt.Errorf("failed to create sub FS: %w", err)
} }
// Load a map of all files to see which ones are available pre-compressed cacheMaxAge := time.Hour * 24
preCompressed, err := listPreCompressedAssets(distFS) fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
if err != nil {
return fmt.Errorf("failed to index pre-compressed frontend assets: %w", err)
}
// Init the file server router.NoRoute(func(c *gin.Context) {
fileServer := NewFileServerWithCaching(http.FS(distFS), preCompressed)
// Handler for Gin
handler := func(c *gin.Context) {
path := strings.TrimPrefix(c.Request.URL.Path, "/") path := strings.TrimPrefix(c.Request.URL.Path, "/")
if strings.HasSuffix(path, "/") { if strings.HasSuffix(path, "/") {
@@ -106,9 +97,7 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
// Serve other static assets with caching // Serve other static assets with caching
c.Request.URL.Path = "/" + path c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request) fileServer.ServeHTTP(c.Writer, c.Request)
} })
router.NoRoute(rateLimitMiddleware, handler)
return nil return nil
} }
@@ -117,138 +106,34 @@ func RegisterFrontend(router *gin.Engine, rateLimitMiddleware gin.HandlerFunc) e
type FileServerWithCaching struct { type FileServerWithCaching struct {
root http.FileSystem root http.FileSystem
lastModified time.Time lastModified time.Time
cacheMaxAge int
lastModifiedHeaderValue string lastModifiedHeaderValue string
preCompressed preCompressedMap cacheControlHeaderValue string
} }
func NewFileServerWithCaching(root http.FileSystem, preCompressed preCompressedMap) *FileServerWithCaching { func NewFileServerWithCaching(root http.FileSystem, maxAge int) *FileServerWithCaching {
return &FileServerWithCaching{ return &FileServerWithCaching{
root: root, root: root,
lastModified: time.Now(), lastModified: time.Now(),
cacheMaxAge: maxAge,
lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat), lastModifiedHeaderValue: time.Now().UTC().Format(http.TimeFormat),
preCompressed: preCompressed, cacheControlHeaderValue: fmt.Sprintf("public, max-age=%d", maxAge),
} }
} }
func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (f *FileServerWithCaching) ServeHTTP(w http.ResponseWriter, r *http.Request) {
// First, set cache headers // Check if the client has a cached version
// Check if the request is for an immutable asset if ifModifiedSince := r.Header.Get("If-Modified-Since"); ifModifiedSince != "" {
if isImmutableAsset(r) { ifModifiedSinceTime, err := time.Parse(http.TimeFormat, ifModifiedSince)
// Set the cache control header as immutable with a long expiration if err == nil && f.lastModified.Before(ifModifiedSinceTime.Add(1*time.Second)) {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") // Client's cached version is up to date
} else { w.WriteHeader(http.StatusNotModified)
// Check if the client has a cached version return
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) 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
}

View File

@@ -1,25 +1,24 @@
module github.com/pocket-id/pocket-id/backend module github.com/pocket-id/pocket-id/backend
go 1.26.0 go 1.25
require ( require (
github.com/aws/aws-sdk-go-v2 v1.41.1 github.com/aws/aws-sdk-go-v2 v1.41.0
github.com/aws/aws-sdk-go-v2/config v1.32.9 github.com/aws/aws-sdk-go-v2/config v1.32.6
github.com/aws/aws-sdk-go-v2/credentials v1.19.9 github.com/aws/aws-sdk-go-v2/credentials v1.19.6
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.95.0
github.com/aws/smithy-go v1.24.1 github.com/aws/smithy-go v1.24.0
github.com/caarlos0/env/v11 v11.3.1 github.com/caarlos0/env/v11 v11.3.1
github.com/cenkalti/backoff/v5 v5.0.3 github.com/cenkalti/backoff/v5 v5.0.3
github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec github.com/disintegration/imageorient v0.0.0-20180920195336-8147d86e83ec
github.com/disintegration/imaging v1.6.2 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-sasl v0.0.0-20241020182733-b788ff22d5a6
github.com/emersion/go-smtp v0.24.0 github.com/emersion/go-smtp v0.24.0
github.com/gin-contrib/slog v1.2.0 github.com/gin-contrib/slog v1.2.0
github.com/gin-gonic/gin v1.11.0 github.com/gin-gonic/gin v1.11.0
github.com/glebarez/go-sqlite v1.22.0 github.com/glebarez/go-sqlite v1.22.0
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/go-co-op/gocron/v2 v2.19.1 github.com/go-co-op/gocron/v2 v2.19.0
github.com/go-ldap/ldap/v3 v3.4.12 github.com/go-ldap/ldap/v3 v3.4.12
github.com/go-playground/validator/v10 v10.30.1 github.com/go-playground/validator/v10 v10.30.1
github.com/go-webauthn/webauthn v0.15.0 github.com/go-webauthn/webauthn v0.15.0
@@ -28,31 +27,30 @@ require (
github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-uuid v1.0.3
github.com/jinzhu/copier v0.4.0 github.com/jinzhu/copier v0.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/lestrrat-go/httprc/v3 v3.0.4 github.com/lestrrat-go/httprc/v3 v3.0.3
github.com/lestrrat-go/jwx/v3 v3.0.13 github.com/lestrrat-go/jwx/v3 v3.0.12
github.com/lmittmann/tint v1.1.3 github.com/lmittmann/tint v1.1.2
github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-isatty v0.0.20
github.com/mileusna/useragent v1.3.5 github.com/mileusna/useragent v1.3.5
github.com/orandin/slog-gorm v1.4.0 github.com/orandin/slog-gorm v1.4.0
github.com/oschwald/maxminddb-golang/v2 v2.1.1 github.com/oschwald/maxminddb-golang/v2 v2.1.1
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.11.1
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 go.opentelemetry.io/contrib/bridges/otelslog v0.14.0
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 go.opentelemetry.io/contrib/exporters/autoexport v0.64.0
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.65.0 go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.64.0
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0
go.opentelemetry.io/otel v1.40.0 go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/log v0.16.0 go.opentelemetry.io/otel/log v0.15.0
go.opentelemetry.io/otel/metric v1.40.0 go.opentelemetry.io/otel/metric v1.39.0
go.opentelemetry.io/otel/sdk v1.40.0 go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/sdk/log v0.16.0 go.opentelemetry.io/otel/sdk/log v0.15.0
go.opentelemetry.io/otel/sdk/metric v1.40.0 go.opentelemetry.io/otel/sdk/metric v1.39.0
go.opentelemetry.io/otel/trace v1.40.0 go.opentelemetry.io/otel/trace v1.39.0
golang.org/x/crypto v0.48.0 golang.org/x/crypto v0.46.0
golang.org/x/image v0.36.0 golang.org/x/image v0.34.0
golang.org/x/net v0.50.0
golang.org/x/sync v0.19.0 golang.org/x/sync v0.19.0
golang.org/x/text v0.34.0 golang.org/x/text v0.32.0
golang.org/x/time v0.14.0 golang.org/x/time v0.14.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1 gorm.io/gorm v1.31.1
@@ -61,24 +59,23 @@ require (
require ( require (
github.com/Azure/go-ntlmssp v0.1.0 // indirect 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/aws/protocol/eventstream v1.7.4 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // 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.17 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.16 // indirect
github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 // indirect github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 // 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/presigned-url v1.13.16 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // 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.5 // 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.10 // 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.14 // 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.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.5 // indirect
github.com/beorn7/perks v1.0.1 // 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/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic v1.14.2 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
@@ -87,22 +84,22 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // 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-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
github.com/go-webauthn/x v0.2.1 // indirect github.com/go-webauthn/x v0.1.27 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect github.com/goccy/go-yaml v1.19.1 // indirect
github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.2.0 // indirect github.com/google/go-querystring v1.2.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -119,56 +116,56 @@ require (
github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect github.com/lestrrat-go/dsig-secp256k1 v1.0.0 // indirect
github.com/lestrrat-go/httpcc v1.0.1 // indirect github.com/lestrrat-go/httpcc v1.0.1 // indirect
github.com/lestrrat-go/option/v2 v2.0.0 // indirect github.com/lestrrat-go/option/v2 v2.0.0 // indirect
github.com/lib/pq v1.11.2 // indirect github.com/lib/pq v1.10.9 // indirect
github.com/mattn/go-sqlite3 v1.14.34 // indirect github.com/mattn/go-sqlite3 v1.14.33 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/ncruces/go-strftime v1.0.0 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.19.2 // indirect github.com/prometheus/procfs v0.19.2 // indirect
github.com/quic-go/qpack v0.6.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect github.com/quic-go/quic-go v0.58.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/robfig/cron/v3 v3.0.1 // indirect github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/spf13/pflag v1.0.10 // indirect github.com/spf13/pflag v1.0.10 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect
github.com/valyala/fastjson v1.6.10 // indirect github.com/valyala/fastjson v1.6.7 // indirect
github.com/x448/float16 v0.8.4 // indirect github.com/x448/float16 v0.8.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 // indirect go.opentelemetry.io/otel/exporters/prometheus v0.61.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 // indirect go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect
golang.org/x/arch v0.24.0 // indirect golang.org/x/arch v0.23.0 // indirect
golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect
golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.41.0 // indirect golang.org/x/oauth2 v0.34.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d // indirect golang.org/x/sys v0.39.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d // indirect google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.79.3 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
google.golang.org/grpc v1.78.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.68.0 // indirect modernc.org/libc v1.67.4 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.46.1 // indirect modernc.org/sqlite v1.42.2 // indirect
) )

View File

@@ -6,55 +6,52 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 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 h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI=
github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.0 h1:tNvqh1s+v0vFYdA1xq0aOJH+Y5cRyZ5upu6roPgPKd4=
github.com/aws/aws-sdk-go-v2 v1.41.1/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0= github.com/aws/aws-sdk-go-v2 v1.41.0/go.mod h1:MayyLB8y+buD9hZqkCW3kX1AKq07Y5pXxtgB+rRFhz0=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 h1:489krEF9xIGkOaaX3CE/Be2uWjiXrkCH6gUX+bZA/BU= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4 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/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4=
github.com/aws/aws-sdk-go-v2/config v1.32.9 h1:ktda/mtAydeObvJXlHzyGpK1xcsLaP16zfUPDGoW90A= github.com/aws/aws-sdk-go-v2/config v1.32.6 h1:hFLBGUKjmLAekvi1evLi5hVvFQtSo3GYwi+Bx4lpJf8=
github.com/aws/aws-sdk-go-v2/config v1.32.9/go.mod h1:U+fCQ+9QKsLW786BCfEjYRj34VVTbPdsLP3CHSYXMOI= 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.9 h1:sWvTKsyrMlJGEuj/WgrwilpoJ6Xa1+KhIpGdzw7mMU8= github.com/aws/aws-sdk-go-v2/credentials v1.19.6 h1:F9vWao2TwjV2MyiyVS+duza0NIRtAslgLUM0vTA1ZaE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.9/go.mod h1:+J44MBhmfVY/lETFiKI+klz0Vym2aCmIjqgClMmW82w= 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.17 h1:I0GyV8wiYrP8XpA70g1HBcQO1JlQxCMTW9npl5UbDHY= 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.17/go.mod h1:tyw7BOl5bBe/oqvoIeECFJjMdzXoa/dfVz3QQ5lgHGA= 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.17 h1:xOLELNKGp2vsiteLsvLPwxC+mYmO6OZ8PYgiuPJzF8U= 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.17/go.mod h1:5M5CI3D12dNOtH3/mk6minaRwI2/37ifCURZISxA/IQ= 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.17 h1:WWLqlh79iO48yLkj1v3ISRNiv+3KdQoZ6JWyfcsyQik= 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.17/go.mod h1:EhG22vHRrvF8oXSTYStZhJc1aUgKtnJe+aOiFEV90cM= 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/internal/ini v1.8.4 h1:WKuaxf++XKWlHWu9ECbMlha8WOEGm0OUEZqm4K/Gcfk= 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/ini v1.8.4/go.mod h1:ZWy7j6v1vWGmPReu0iSGvRiise4YI5SkR3OHKTZ6Wuc=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17 h1:JqcdRG//czea7Ppjb+g/n4o8i/R50aTBHkA7vu0lK+k= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.16 h1:CjMzUs78RDDv4ROu3JnJn/Ig1r6ZD7/T2DXLLRpejic=
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/internal/v4a v1.4.16/go.mod h1:uVW4OLBqbJXSHJYA9svT9BluSvvwbzLQ2Crf6UPzR3c=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 h1:0ryTNEdJbzUCEWkVXEXoqlXV72J5keC1GvILMOuD00E= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.4 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/accept-encoding v1.13.4/go.mod h1:HQ4qwNZh32C3CBeO6iJLQlgtMzqeG17ziAA/3KDJFow=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8 h1:Z5EiPIzXKewUQK0QTMkutjiaPVeVYXX7KIqhXu/0fXs= github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.7 h1:DIBqIrJ7hv+e4CmIk2z3pyKT+3B6qVMgRsawHiR3qso=
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/checksum v1.9.7/go.mod h1:vLm00xmBke75UmpNvOcZQ/Q30ZFjbczeLFqGx5urmGo=
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.16 h1:oHjJHeUy0ImIV0bsrX0X91GkV5nJAyv1l1CC9lnO0TI=
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/presigned-url v1.13.16/go.mod h1:iRSNGgOYmiYwSCXxXaKb9HfOEj40+oTKn8pTxMlYkRM=
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.16 h1:NSbvS17MlI2lurYgXnCOLvCFX38sBW4eiVER7+kkgsU=
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/internal/s3shared v1.19.16/go.mod h1:SwT8Tmqd4sA6G1qaGdzWCJN99bUmPGHfRwwq3G5Qb+A=
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.95.0 h1:MIWra+MSq53CFaXXAywB2qg9YvVZifkk6vEGl/1Qor0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= 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.5 h1:VrhDvQib/i0lxvr3zqlUwLwJP4fpmpyD9wYG1vfSu+Y= 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.5/go.mod h1:k029+U8SY30/3/ras4G/Fnv/b88N4mAfliNn08Dem4M= 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.10 h1:+VTRawC4iVY58pS/lzpo0lnoa/SYNGF4/B/3/U5ro8Y= 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.10/go.mod h1:yifAsgBxgJWn3ggx70A3urX2AN49Y5sJTD1UQFlfqBw= 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.14 h1:0jbJeuEHlwKJ9PfXtpSFc4MF+WIWORdhN1n30ITZGFM= 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.14/go.mod h1:sTGThjphYE4Ohw8vJiRStAcu3rbjtXRsdNB0TvZ5wwo= 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.6 h1:5fFjR/ToSOzB2OQ/XqWpZBmNvmP/pJ1jOWYlFDJTjRQ= 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.6/go.mod h1:qgFDZQSD/Kys7nJnVqYlWKnh0SSdMjAi0uSwON4wgYQ= 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.1 h1:VbyeNfmYkWoxMVpGUAbQumkODcYmfMRfZ8yQiH30SK0= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.1/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 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/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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= 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= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
@@ -91,8 +88,6 @@ 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-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 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= 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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk=
@@ -103,8 +98,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= 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 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk= github.com/gin-contrib/slog v1.2.0 h1:vAxZfr7knD1ZYK5+pMJLP52sZXIkJXkcRPa/0dx9hSk=
github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI= github.com/gin-contrib/slog v1.2.0/go.mod h1:vYK6YltmpsEFkO0zfRMLTKHrWS3DwUSn0TMpT+kMagI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
@@ -117,8 +112,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/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 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-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-co-op/gocron/v2 v2.19.1 h1:B4iLeA0NB/2iO3EKQ7NfKn5KsQgZfjb2fkvoZJU3yBI= github.com/go-co-op/gocron/v2 v2.19.0 h1:OKf2y6LXPs/BgBI2fl8PxUpNAI1DA9Mg+hSeGOS38OU=
github.com/go-co-op/gocron/v2 v2.19.1/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U= github.com/go-co-op/gocron/v2 v2.19.0/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 h1:1b81mv7MagXZ7+1r7cLTWmyuTqVqdwbtJSjC0DAp9s4=
github.com/go-ldap/ldap/v3 v3.4.12/go.mod h1:+SPAGcTtOfmGsCb3h1RFiq4xpp4N636G75OEace8lNo= 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= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
@@ -134,20 +129,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/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 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM= github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY= github.com/go-webauthn/webauthn v0.15.0 h1:LR1vPv62E0/6+sTenX35QrCmpMCzLeVAcnXeH4MrbJY=
github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A= github.com/go-webauthn/webauthn v0.15.0/go.mod h1:hcAOhVChPRG7oqG7Xj6XKN1mb+8eXTGP/B7zBLzkX5A=
github.com/go-webauthn/x v0.2.1 h1:/oB8i0FhSANuoN+YJF5XHMtppa7zGEYaQrrf6ytotjc= github.com/go-webauthn/x v0.1.27 h1:CLyuB8JGn9xvw0etBl4fnclcbPTwhKpN4Xg32zaSYnI=
github.com/go-webauthn/x v0.2.1/go.mod h1:Wm0X0zXkzznit4gHj4m82GiBZRMEm+TDUIoJWIQLsE4= github.com/go-webauthn/x v0.1.27/go.mod h1:KGYJQAPPgbpDKi4N7zKMGL+Iz6WgxKg3OlhVbPtuJXI=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= 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-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.1 h1:3rG3+v8pkhRqoQ/88NYNMHYVGYztCOCIZ7UQhu7H+NE=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/goccy/go-yaml v1.19.1/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA=
github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -171,8 +166,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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4 h1:kEISI/Gx67NzH3nJxAmY/dGac80kKZgZt134u7Y/k1s=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.4/go.mod h1:6Nz966r3vQYCqIzWsuEl9d7cf7mRhtDmm++sOxlnfxI=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 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/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
@@ -231,20 +226,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/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 h1:ydWCStUeJLkpYyjLDHihupbn2tYmZ7m22BGkcvZZrIE=
github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E=
github.com/lestrrat-go/httprc/v3 v3.0.4 h1:pXyH2ppK8GYYggygxJ3TvxpCZnbEUWc9qSwRTTApaLA= github.com/lestrrat-go/httprc/v3 v3.0.3 h1:WjLHWkDkgWXeIUrKi/7lS/sGq2DjkSAwdTbH5RHXAKs=
github.com/lestrrat-go/httprc/v3 v3.0.4/go.mod h1:mSMtkZW92Z98M5YoNNztbRGxbXHql7tSitCvaxvo9l0= github.com/lestrrat-go/httprc/v3 v3.0.3/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.12 h1:p25r68Y4KrbBdYjIsQweYxq794CtGCzcrc5dGzJIRjg=
github.com/lestrrat-go/jwx/v3 v3.0.13/go.mod h1:2m0PV1A9tM4b/jVLMx8rh6rBl7F6WGb3EG2hufN9OQU= github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8=
github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLOcID3Ss= 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/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I= github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w=
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk= github.com/mattn/go-sqlite3 v1.14.33 h1:A5blZ5ulQo2AtayQ9/limgHEkFreKj1Dv226a1K73s0=
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mattn/go-sqlite3 v1.14.33/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 h1:SJM5NzBmh/hO+4LGeATKpaEX9+b4vcGg2qXGLiNGDws=
github.com/mileusna/useragent v1.3.5/go.mod h1:3d8TOmwL/5I8pJjyVDteHtgDGcefrFUX4ccGOMKNYYc= 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= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
@@ -262,8 +257,6 @@ 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/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 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 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 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= 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= github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
@@ -283,16 +276,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_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 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= 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 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= 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 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.58.0 h1:ggY2pvZaVdB9EyojxL1p+5mptkuHyX5MOSv4dgWF4Ug=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= github.com/quic-go/quic-go v0.58.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 h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 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= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
@@ -323,63 +316,62 @@ 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/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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.7 h1:ZE4tRy0CIkh+qDc5McjatheGX2czdn8slQjomexVpBM=
github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= github.com/valyala/fastjson v1.6.7/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 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/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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0 h1:eypSOd+0txRKCXPNyqLPsbSfA0jULgJcGmSAdFAnrCM=
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk= go.opentelemetry.io/contrib/bridges/otelslog v0.14.0/go.mod h1:CRGvIBL/aAxpQU34ZxyQVFlovVcp67s4cAmQu8Jh9mc=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0 h1:I/7S/yWobR3QHFLqHsJ8QOndoiFsj1VgHpQiq43KlUI= go.opentelemetry.io/contrib/bridges/prometheus v0.64.0 h1:7TYhBCu6Xz6vDJGNtEslWZLuuX2IJ/aH50hBY4MVeUg=
go.opentelemetry.io/contrib/bridges/prometheus v0.65.0/go.mod h1:jPF6gn3y1E+nozCAEQj3c6NZ8KY+tvAgSVfvoOJUFac= go.opentelemetry.io/contrib/bridges/prometheus v0.64.0/go.mod h1:tHQctZfAe7e4PBPGyt3kae6mQFXNpj+iiDJa3ithM50=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0 h1:2gApdml7SznX9szEKFjKjM4qGcGSvAybYLBY319XG3g= go.opentelemetry.io/contrib/exporters/autoexport v0.64.0 h1:9pzPj3RFyKOxBAMkM2w84LpT+rdHam1XoFA+QhARiRw=
go.opentelemetry.io/contrib/exporters/autoexport v0.65.0/go.mod h1:0QqAGlbHXhmPYACG3n5hNzO5DnEqqtg4VcK5pr22RI0= 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.65.0 h1:LSJsvNqhj2sBNFb5NWHbyDK4QJ/skQ2ydjeOZ9OYNZ4= 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.65.0/go.mod h1:0Q5ocj6h/+C6KYq8cnl4tDFVd4I1HBdsJ440aeagHos= 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.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0 h1:ssfIgGNANqpVFCndZvcuyKbl0g+UAVcbBcqGkG28H0Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.64.0/go.mod h1:GQ/474YrbE4Jx8gZ4q5I4hrhUzM6UPzyrqJYV2AqPoQ=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0 h1:xariChe8OOVF3rNlfzGFgQc61npQmXhzZj/i82mxMfg= go.opentelemetry.io/contrib/propagators/b3 v1.39.0 h1:PI7pt9pkSnimWcp5sQhUA9OzLbc3Ba4sL+VEUTNsxrk=
go.opentelemetry.io/contrib/propagators/b3 v1.40.0/go.mod h1:72WvbdxbOfXaELEQfonFfOL6osvcVjI7uJEE8C2nkrs= go.opentelemetry.io/contrib/propagators/b3 v1.39.0/go.mod h1:5gV/EzPnfYIwjzj+6y8tbGW2PKWhcsz5e/7twptRVQY=
go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0 h1:ZVg+kCXxd9LtAaQNKBxAvJ5NpMf7LpvEr4MIZqb0TMQ= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.15.0 h1:W+m0g+/6v3pa5PgVf2xoFMi5YtNR06WtS7ve5pcvLtM=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.16.0/go.mod h1:hh0tMeZ75CCXrHd9OXRYxTlCAdxcXioWHFIpYw2rZu8= 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.16.0 h1:djrxvDxAe44mJUrKataUbOhCKhR3F8QCyWucO16hTQs= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.15.0 h1:EKpiGphOYq3CYnIe2eX9ftUkyU+Y8Dtte8OaWyHJ4+I=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.16.0/go.mod h1:dt3nxpQEiSoKvfTVxp3TUg5fHPLhKtbcnN3Z1I1ePD0= 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.40.0 h1:NOyNnS19BF2SUDApbOKbDtWZ0IK7b8FJ2uAGdIWOGb0= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0 h1:cEf8jF6WbuGQWUVcqgyWtTR0kOOAWY1DYZ+UhvdmQPw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.40.0/go.mod h1:VL6EgVikRLcJa9ftukrHu/ZkkhFBSo1lzvdBC9CF1ss= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.39.0/go.mod h1:k1lzV5n5U3HkGvTCJHraTAGJ7MqsgL1wrGwTj1Isfiw=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0 h1:9y5sHvAxWzft1WQ4BwqcvA+IFVUJ1Ya75mSAUnFEVwE= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0 h1:nKP4Z2ejtHn3yShBb+2KawiXgpn8In5cT7aO2wXuOTE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.40.0/go.mod h1:eQqT90eR3X5Dbs1g9YSM30RavwLF725Ris5/XSXWvqE= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.39.0/go.mod h1:NwjeBbNigsO4Aj9WgM0C+cKIrxsZUaRmZUO7A8I7u8o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0 h1:QKdN8ly8zEMrByybbQgv8cWBcdAarwmIPZ6FThrWXJs= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.40.0/go.mod h1:bTdK1nhqF76qiPoCCdyFIV+N/sRHYXYCTQc+3VCi3MI= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0 h1:DvJDOPmSWQHWywQS6lKL+pb8s3gBLOZUtw4N+mavW1I= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.40.0/go.mod h1:EtekO9DEJb4/jRyN4v4Qjc2yA7AtfCBuz2FynRUWTXs= 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.40.0 h1:wVZXIWjQSeSmMoxF74LzAnpVQOAFDo3pPji9Y4SOFKc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.40.0/go.mod h1:khvBS2IggMFNwZK/6lEeHg/W57h/IX6J4URh57fuI40= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= go.opentelemetry.io/otel/exporters/prometheus v0.61.0 h1:cCyZS4dr67d30uDyh8etKM2QyDsQ4zC9ds3bdbrVoD0=
go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= go.opentelemetry.io/otel/exporters/prometheus v0.61.0/go.mod h1:iivMuj3xpR2DkUrUya3TPS/Z9h3dz7h01GxU+fQBRNg=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0 h1:ivlbaajBWJqhcCPniDqDJmRwj4lc6sRT+dCAVKNmxlQ= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0 h1:0BSddrtQqLEylcErkeFrJBmwFzcqfQq9+/uxfTZq+HE=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.16.0/go.mod h1:u/G56dEKDDwXNCVLsbSrllB2o8pbtFLUC4HpR66r2dc= go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.15.0/go.mod h1:87sjYuAPzaRCtdd09GU5gM1U9wQLrrcYrm77mh5EBoc=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0 h1:ZrPRak/kS4xI3AVXy8F7pipuDXmDsrO8Lg+yQjBLjw0= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0 h1:5gn2urDL/FBnK8OkCfD1j3/ER79rUuTYmCvlXBKeYL8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.40.0/go.mod h1:3y6kQCWztq6hyW8Z9YxQDDm0Je9AJoFar2G0yDcmhRk= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.39.0/go.mod h1:0fBG6ZJxhqByfFZDwSwpZGzJU671HkwpWaNe2t4VUPI=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0 h1:MzfofMZN8ulNqobCmCAVbqVL5syHw+eB2qPRkCMA/fQ= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0 h1:8UPA4IbVZxpsD76ihGOQiFml99GPAEZLohDXvqHdi6U=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.40.0/go.mod h1:E73G9UFtKRXrxhBsHtG00TB5WxX57lpsQzogDkqBTz8= go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.39.0/go.mod h1:MZ1T/+51uIVKlRzGw1Fo46KEWThjlCBZKl2LzY5nv4g=
go.opentelemetry.io/otel/log v0.16.0 h1:DeuBPqCi6pQwtCK0pO4fvMB5eBq6sNxEnuTs88pjsN4= go.opentelemetry.io/otel/log v0.15.0 h1:0VqVnc3MgyYd7QqNVIldC3dsLFKgazR6P3P3+ypkyDY=
go.opentelemetry.io/otel/log v0.16.0/go.mod h1:rWsmqNVTLIA8UnwYVOItjyEZDbKIkMxdQunsIhpUMes= go.opentelemetry.io/otel/log v0.15.0/go.mod h1:9c/G1zbyZfgu1HmQD7Qj84QMmwTp2QCQsZH1aeoWDE4=
go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/log v0.16.0 h1:e/b4bdlQwC5fnGtG3dlXUrNOnP7c8YLVSpSfEBIkTnI= go.opentelemetry.io/otel/sdk/log v0.15.0 h1:WgMEHOUt5gjJE93yqfqJOkRflApNif84kxoHWS9VVHE=
go.opentelemetry.io/otel/sdk/log v0.16.0/go.mod h1:JKfP3T6ycy7QEuv3Hj8oKDy7KItrEkus8XJE6EoSzw4= go.opentelemetry.io/otel/sdk/log v0.15.0/go.mod h1:qDC/FlKQCXfH5hokGsNg9aUBGMJQsrUyeOiW5u+dKBQ=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0 h1:/XVkpZ41rVRTP4DfMgYv1nEtNmf65XPPyAdqV90TMy4= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0 h1:Ijbtz+JKXl8T2MngiwqBlPaHqc4YCaP/i13Qrow6gAM=
go.opentelemetry.io/otel/sdk/log/logtest v0.16.0/go.mod h1:iOOPgQr5MY9oac/F5W86mXdeyWZGleIx3uXO98X2R6Y= go.opentelemetry.io/otel/sdk/log/logtest v0.14.0/go.mod h1:dCU8aEL6q+L9cYTqcVOk8rM9Tp8WdnHOPLiBgp0SGOA=
go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -389,89 +381,55 @@ 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 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= 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= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.24.0 h1:qlJ3M9upxvFfwRM51tTg3Yl+8CP9vCC1E7vlFpgv99Y= golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-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.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1iimyPKZ/xwniHj8Q2a0=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU=
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.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.36.0 h1:Iknbfm1afbgtwPTmHnS2gTM/6PPZfH+z2EFuOkSbqwc= golang.org/x/image v0.34.0 h1:33gCkyw9hmwbZJeZkct8XyR11yH889EQt/QH4VmXMn8=
golang.org/x/image v0.36.0/go.mod h1:YsWD2TyyGKiIX1kZlu9QfKIsQ4nAAK9bdgdrIsE7xy4= golang.org/x/image v0.34.0/go.mod h1:2RNFBZRB+vnwwFil8GkMdRvrJOFd1AzdZI6vOY+eJVU=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
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-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.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
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.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/oauth2 v0.34.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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= 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-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-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-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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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-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.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.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.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
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 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= 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.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
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= 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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 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/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= google.golang.org/genproto/googleapis/api v0.0.0-20251222181119-0a764e51fe1b h1:uA40e2M6fYRBf0+8uN5mLlqUtV192iiksiICIBkYJ1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= 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-20260217215200-42d3e9bedb6d h1:t/LOSXPJ9R0B6fnZNyALBRfZBH0Uy0gT+uR+SJ6syqQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -486,18 +444,18 @@ gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 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 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= 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 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE=
modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= modernc.org/libc v1.67.4 h1:zZGmCMUVPORtKv95c2ReQN5VDjvkoRm9GWPTEPuvlWg=
modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/libc v1.67.4/go.mod h1:QvvnnJ5P7aitu0ReNpVIEyesuhmDLQ8kaEoyMjIFZJA=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
@@ -506,8 +464,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= 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 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.42.2 h1:7hkZUNJvJFN2PgfUdjni9Kbvd4ef4mNLOu0B9FGxM74=
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= modernc.org/sqlite v1.42.2/go.mod h1:+VkC6v3pLOAE0A0uVucQEcbVW0I5nHCeDaBf+DpsQT8=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=

View File

@@ -12,7 +12,6 @@ import (
"log/slog" "log/slog"
"os" "os"
"path" "path"
"strings"
"github.com/pocket-id/pocket-id/backend/internal/storage" "github.com/pocket-id/pocket-id/backend/internal/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -21,8 +20,6 @@ import (
// initApplicationImages copies the images from the embedded directory to the storage backend // 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. // 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) { func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage) (map[string]string, error) {
// Previous versions of images // Previous versions of images
// If these are found, they are deleted // If these are found, they are deleted
@@ -79,18 +76,6 @@ func initApplicationImages(ctx context.Context, fileStorage storage.FileStorage)
dstNameToExt[nameWithoutExt] = ext 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 // Copy images from the images directory to the application-images directory if they don't already exist
for _, sourceFile := range sourceFiles { for _, sourceFile := range sourceFiles {
if sourceFile.IsDir() { if sourceFile.IsDir() {

View File

@@ -2,7 +2,6 @@ package bootstrap
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
@@ -12,7 +11,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common" "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/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/storage"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )
@@ -62,9 +60,7 @@ func Bootstrap(ctx context.Context) error {
} }
waitUntil, err := svc.appLockService.Acquire(ctx, false) waitUntil, err := svc.appLockService.Acquire(ctx, false)
if errors.Is(err, service.ErrLockUnavailable) { if err != nil {
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) return fmt.Errorf("failed to acquire application lock: %w", err)
} }

View File

@@ -34,8 +34,7 @@ func NewDatabase() (db *gorm.DB, err error) {
} }
// Run migrations // Run migrations
err = utils.MigrateDatabase(sqlDb) if err := utils.MigrateDatabase(sqlDb); err != nil {
if err != nil {
return nil, fmt.Errorf("failed to run migrations: %w", err) return nil, fmt.Errorf("failed to run migrations: %w", err)
} }
@@ -43,10 +42,7 @@ func NewDatabase() (db *gorm.DB, err error) {
} }
func ConnectDatabase() (db *gorm.DB, err error) { func ConnectDatabase() (db *gorm.DB, err error) {
var ( var dialector gorm.Dialector
dialector gorm.Dialector
sqliteNetworkFilesystem bool
)
// Choose the correct database provider // Choose the correct database provider
var onConnFn func(conn *sql.DB) var onConnFn func(conn *sql.DB)
@@ -67,14 +63,6 @@ func ConnectDatabase() (db *gorm.DB, err error) {
if err := ensureSqliteDatabaseDir(dbPath); err != nil { if err := ensureSqliteDatabaseDir(dbPath); err != nil {
return nil, err 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 // Before we connect, also make sure that there's a temporary folder for SQLite to write its data

View File

@@ -118,10 +118,11 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
// Set the logger provider globally // Set the logger provider globally
globallog.SetLoggerProvider(provider) globallog.SetLoggerProvider(provider)
handler = slog.NewMultiHandler( // Wrap the handler in a "fanout" one
handler = utils.LogFanoutHandler{
handler, handler,
otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)), otelslog.NewHandler(common.Name, otelslog.WithLoggerProvider(provider)),
) }
// Set the default slog to send logs to OTel and add the app name // Set the default slog to send logs to OTel and add the app name
log := slog.New(handler). log := slog.New(handler).

View File

@@ -15,8 +15,6 @@ import (
sloggin "github.com/gin-contrib/slog" sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" "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" "golang.org/x/time/rate"
"gorm.io/gorm" "gorm.io/gorm"
@@ -49,14 +47,12 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
_ = r.SetTrustedProxies(nil) _ = r.SetTrustedProxies(nil)
} }
if common.EnvConfig.TrustedPlatform != "" {
r.TrustedPlatform = common.EnvConfig.TrustedPlatform
}
if common.EnvConfig.TracingEnabled { if common.EnvConfig.TracingEnabled {
r.Use(otelgin.Middleware(common.Name)) r.Use(otelgin.Middleware(common.Name))
} }
rateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 60)
// Setup global middleware // Setup global middleware
r.Use(middleware.HeadMiddleware()) r.Use(middleware.HeadMiddleware())
r.Use(middleware.NewCacheControlMiddleware().Add()) r.Use(middleware.NewCacheControlMiddleware().Add())
@@ -64,8 +60,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
r.Use(middleware.NewCspMiddleware().Add()) r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add())
frontendRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(100*time.Millisecond), 300) err := frontend.RegisterFrontend(r)
err := frontend.RegisterFrontend(r, frontendRateLimitMiddleware)
if errors.Is(err, frontend.ErrFrontendNotIncluded) { if errors.Is(err, frontend.ErrFrontendNotIncluded) {
slog.Warn("Frontend is not included in the build. Skipping frontend registration.") slog.Warn("Frontend is not included in the build. Skipping frontend registration.")
} else if err != nil { } else if err != nil {
@@ -76,10 +71,8 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService) authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyService, svc.userService, svc.jwtService)
fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware()
apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100)
// Set up API routes // Set up API routes
apiGroup := r.Group("/api", apiRateLimitMiddleware) apiGroup := r.Group("/api", rateLimitMiddleware)
controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService) controller.NewApiKeyController(apiGroup, authMiddleware, svc.apiKeyService)
controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService) controller.NewWebauthnController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.webauthnService, svc.appConfigService)
controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService, svc.jwtService)
@@ -89,7 +82,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware)
controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService) controller.NewUserGroupController(apiGroup, authMiddleware, svc.userGroupService)
controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService) controller.NewCustomClaimController(apiGroup, authMiddleware, svc.customClaimService)
controller.NewVersionController(apiGroup, authMiddleware, svc.versionService) controller.NewVersionController(apiGroup, svc.versionService)
controller.NewScimController(apiGroup, authMiddleware, svc.scimService) controller.NewScimController(apiGroup, authMiddleware, svc.scimService)
controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService) controller.NewUserSignupController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userSignUpService, svc.appConfigService)
@@ -101,23 +94,18 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
} }
// Set up base routes // Set up base routes
baseGroup := r.Group("/", apiRateLimitMiddleware) baseGroup := r.Group("/", rateLimitMiddleware)
controller.NewWellKnownController(baseGroup, svc.jwtService) controller.NewWellKnownController(baseGroup, svc.jwtService)
// Set up healthcheck routes // Set up healthcheck routes
// These are not rate-limited // These are not rate-limited
controller.NewHealthzController(r) controller.NewHealthzController(r)
var protocols http.Protocols
protocols.SetHTTP1(true)
protocols.SetUnencryptedHTTP2(true)
// Set up the server // Set up the server
srv := &http.Server{ srv := &http.Server{
MaxHeaderBytes: 1 << 20, MaxHeaderBytes: 1 << 20,
ReadHeaderTimeout: 10 * time.Second, ReadHeaderTimeout: 10 * time.Second,
Protocols: &protocols, Handler: http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
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 // HEAD requests don't get matched by Gin routes, so we convert them to GET
// middleware.HeadMiddleware will convert them back to HEAD later // middleware.HeadMiddleware will convert them back to HEAD later
if req.Method == http.MethodHead { if req.Method == http.MethodHead {
@@ -127,7 +115,7 @@ func initRouter(db *gorm.DB, svc *services) (utils.Service, error) {
} }
r.ServeHTTP(w, req) r.ServeHTTP(w, req)
}), &http2.Server{}), }),
} }
// Set up the listener // Set up the listener

View File

@@ -75,12 +75,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima
svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService) svc.userGroupService = service.NewUserGroupService(db, svc.appConfigService, svc.scimService)
svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage) svc.userService = service.NewUserService(db, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService, svc.customClaimService, svc.appImagesService, svc.scimService, fileStorage)
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService, fileStorage)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.apiKeyService, err = service.NewApiKeyService(ctx, db, svc.emailService)
if err != nil {
return nil, fmt.Errorf("failed to create API key service: %w", err)
}
svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService) svc.userSignUpService = service.NewUserSignupService(db, svc.jwtService, svc.auditLogService, svc.appConfigService, svc.userService)
svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService) svc.oneTimeAccessService = service.NewOneTimeAccessService(db, svc.userService, svc.jwtService, svc.auditLogService, svc.emailService, svc.appConfigService)

View File

@@ -119,10 +119,11 @@ func acquireImportLock(ctx context.Context, db *gorm.DB, force bool) error {
defer cancel() defer cancel()
waitUntil, err := appLockService.Acquire(opCtx, force) waitUntil, err := appLockService.Acquire(opCtx, force)
if errors.Is(err, service.ErrLockUnavailable) { if err != nil {
//nolint:staticcheck if errors.Is(err, service.ErrLockUnavailable) {
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") //nolint:staticcheck
} else if err != nil { return errors.New("Pocket ID must be stopped before importing data; please stop the running instance or run with --forcefully-acquire-lock to terminate the other instance")
}
return fmt.Errorf("failed to acquire application lock: %w", err) return fmt.Errorf("failed to acquire application lock: %w", err)
} }

View File

@@ -44,15 +44,11 @@ type EnvConfigSchema struct {
DbProvider DbProvider DbProvider DbProvider
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
TrustProxy bool `env:"TRUST_PROXY"` TrustProxy bool `env:"TRUST_PROXY"`
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"` AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"` AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"` InternalAppURL string `env:"INTERNAL_APP_URL"`
UiConfigDisabled bool `env:"UI_CONFIG_DISABLED"` 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"` FileBackend string `env:"FILE_BACKEND" options:"toLower"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
@@ -74,10 +70,11 @@ type EnvConfigSchema struct {
GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBPath string `env:"GEOLITE_DB_PATH"`
GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"`
LogLevel string `env:"LOG_LEVEL" options:"toLower"` LogLevel string `env:"LOG_LEVEL" options:"toLower"`
MetricsEnabled bool `env:"METRICS_ENABLED"` MetricsEnabled bool `env:"METRICS_ENABLED"`
TracingEnabled bool `env:"TRACING_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"`
LogJSON bool `env:"LOG_JSON"` LogJSON bool `env:"LOG_JSON"`
DisableRateLimiting bool `env:"DISABLE_RATE_LIMITING"`
} }
var EnvConfig = defaultConfig() var EnvConfig = defaultConfig()
@@ -107,7 +104,7 @@ func defaultConfig() EnvConfigSchema {
func parseEnvConfig() error { func parseEnvConfig() error {
parsers := map[reflect.Type]env.ParserFunc{ parsers := map[reflect.Type]env.ParserFunc{
reflect.TypeFor[[]byte](): func(value string) (any, error) { reflect.TypeOf([]byte{}): func(value string) (interface{}, error) {
return []byte(value), nil return []byte(value), nil
}, },
} }
@@ -130,10 +127,6 @@ func parseEnvConfig() error {
// ValidateEnvConfig checks the EnvConfig for required fields and valid values // ValidateEnvConfig checks the EnvConfig for required fields and valid values
func ValidateEnvConfig(config *EnvConfigSchema) error { func ValidateEnvConfig(config *EnvConfigSchema) error {
if shouldSkipEnvValidation(os.Args) {
return nil
}
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil { if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'") return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
} }
@@ -185,8 +178,8 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
} }
// Validate LOCAL_IPV6_RANGES // Validate LOCAL_IPV6_RANGES
ranges := strings.SplitSeq(config.LocalIPv6Ranges, ",") ranges := strings.Split(config.LocalIPv6Ranges, ",")
for rangeStr := range ranges { for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr) rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" { if rangeStr == "" {
continue continue
@@ -207,25 +200,10 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0") return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
} }
if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 {
return errors.New("STATIC_API_KEY must be at least 16 characters long")
}
return nil return nil
} }
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 // prepareEnvConfig processes special options for EnvConfig fields
func prepareEnvConfig(config *EnvConfigSchema) error { func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem() val := reflect.ValueOf(config).Elem()
@@ -236,9 +214,9 @@ func prepareEnvConfig(config *EnvConfigSchema) error {
fieldType := typ.Field(i) fieldType := typ.Field(i)
optionsTag := fieldType.Tag.Get("options") optionsTag := fieldType.Tag.Get("options")
options := strings.SplitSeq(optionsTag, ",") options := strings.Split(optionsTag, ",")
for option := range options { for _, option := range options {
switch option { switch option {
case "toLower": case "toLower":
if field.Kind() == reflect.String { if field.Kind() == reflect.String {

View File

@@ -20,7 +20,7 @@ type AlreadyInUseError struct {
func (e *AlreadyInUseError) Error() string { func (e *AlreadyInUseError) Error() string {
return e.Property + " is already in use" return e.Property + " is already in use"
} }
func (e *AlreadyInUseError) HttpStatusCode() int { return http.StatusBadRequest } func (e *AlreadyInUseError) HttpStatusCode() int { return 400 }
func (e *AlreadyInUseError) Is(target error) bool { func (e *AlreadyInUseError) Is(target error) bool {
// Ignore the field property when checking if an error is of the type AlreadyInUseError // 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{} type SetupAlreadyCompletedError struct{}
func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" } func (e *SetupAlreadyCompletedError) Error() string { return "setup already completed" }
func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return http.StatusConflict } func (e *SetupAlreadyCompletedError) HttpStatusCode() int { return 400 }
type TokenInvalidOrExpiredError struct{} type TokenInvalidOrExpiredError struct{}
func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" } func (e *TokenInvalidOrExpiredError) Error() string { return "token is invalid or expired" }
func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *TokenInvalidOrExpiredError) HttpStatusCode() int { return 400 }
type DeviceCodeInvalid struct{} type DeviceCodeInvalid struct{}
func (e *DeviceCodeInvalid) Error() string { func (e *DeviceCodeInvalid) Error() string {
return "one time access code must be used on the device it was generated for" return "one time access code must be used on the device it was generated for"
} }
func (e *DeviceCodeInvalid) HttpStatusCode() int { return http.StatusUnauthorized } func (e *DeviceCodeInvalid) HttpStatusCode() int { return 400 }
type TokenInvalidError struct{} type TokenInvalidError struct{}
func (e *TokenInvalidError) Error() string { func (e *TokenInvalidError) Error() string {
return "Token is invalid" return "Token is invalid"
} }
func (e *TokenInvalidError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *TokenInvalidError) HttpStatusCode() int { return 400 }
type OidcMissingAuthorizationError struct{} type OidcMissingAuthorizationError struct{}
@@ -60,51 +60,46 @@ func (e *OidcMissingAuthorizationError) HttpStatusCode() int { return http.Statu
type OidcGrantTypeNotSupportedError struct{} type OidcGrantTypeNotSupportedError struct{}
func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" } func (e *OidcGrantTypeNotSupportedError) Error() string { return "grant type not supported" }
func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest } func (e *OidcGrantTypeNotSupportedError) HttpStatusCode() int { return 400 }
type OidcMissingClientCredentialsError struct{} type OidcMissingClientCredentialsError struct{}
func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" } func (e *OidcMissingClientCredentialsError) Error() string { return "client id or secret not provided" }
func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return http.StatusBadRequest } func (e *OidcMissingClientCredentialsError) HttpStatusCode() int { return 400 }
type OidcClientSecretInvalidError struct{} type OidcClientSecretInvalidError struct{}
func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" } func (e *OidcClientSecretInvalidError) Error() string { return "invalid client secret" }
func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *OidcClientSecretInvalidError) HttpStatusCode() int { return 400 }
type OidcClientAssertionInvalidError struct{} type OidcClientAssertionInvalidError struct{}
func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" } func (e *OidcClientAssertionInvalidError) Error() string { return "invalid client assertion" }
func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return http.StatusUnauthorized } func (e *OidcClientAssertionInvalidError) HttpStatusCode() int { return 400 }
type OidcInvalidAuthorizationCodeError struct{} type OidcInvalidAuthorizationCodeError struct{}
func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" } func (e *OidcInvalidAuthorizationCodeError) Error() string { return "invalid authorization code" }
func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return http.StatusBadRequest } func (e *OidcInvalidAuthorizationCodeError) HttpStatusCode() int { return 400 }
type OidcClientNotFoundError struct{}
func (e *OidcClientNotFoundError) Error() string { return "client not found" }
func (e *OidcClientNotFoundError) HttpStatusCode() int { return http.StatusNotFound }
type OidcMissingCallbackURLError struct{} type OidcMissingCallbackURLError struct{}
func (e *OidcMissingCallbackURLError) Error() string { func (e *OidcMissingCallbackURLError) Error() string {
return "unable to detect callback url, it might be necessary for an admin to fix this" return "unable to detect callback url, it might be necessary for an admin to fix this"
} }
func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest } func (e *OidcMissingCallbackURLError) HttpStatusCode() int { return 400 }
type OidcInvalidCallbackURLError struct{} type OidcInvalidCallbackURLError struct{}
func (e *OidcInvalidCallbackURLError) Error() string { func (e *OidcInvalidCallbackURLError) Error() string {
return "invalid callback URL, it might be necessary for an admin to fix this" return "invalid callback URL, it might be necessary for an admin to fix this"
} }
func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return http.StatusBadRequest } func (e *OidcInvalidCallbackURLError) HttpStatusCode() int { return 400 }
type FileTypeNotSupportedError struct{} type FileTypeNotSupportedError struct{}
func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" } func (e *FileTypeNotSupportedError) Error() string { return "file type not supported" }
func (e *FileTypeNotSupportedError) HttpStatusCode() int { return http.StatusBadRequest } func (e *FileTypeNotSupportedError) HttpStatusCode() int { return 400 }
type FileTooLargeError struct { type FileTooLargeError struct {
MaxSize string MaxSize string
@@ -139,20 +134,6 @@ func (e *TooManyRequestsError) Error() string {
} }
func (e *TooManyRequestsError) HttpStatusCode() int { return http.StatusTooManyRequests } 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{} type ClientIdOrSecretNotProvidedError struct{}
func (e *ClientIdOrSecretNotProvidedError) Error() string { func (e *ClientIdOrSecretNotProvidedError) Error() string {
@@ -299,13 +280,6 @@ func (e *APIKeyExpirationDateError) Error() string {
} }
func (e *APIKeyExpirationDateError) HttpStatusCode() int { return http.StatusBadRequest } 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{} type OidcInvalidRefreshTokenError struct{}
func (e *OidcInvalidRefreshTokenError) Error() string { func (e *OidcInvalidRefreshTokenError) Error() string {

View File

@@ -26,11 +26,12 @@ func NewApiKeyController(group *gin.RouterGroup, authMiddleware *middleware.Auth
uc := &ApiKeyController{apiKeyService: apiKeyService} uc := &ApiKeyController{apiKeyService: apiKeyService}
apiKeyGroup := group.Group("/api-keys") apiKeyGroup := group.Group("/api-keys")
apiKeyGroup.Use(authMiddleware.WithAdminNotRequired().Add())
{ {
apiKeyGroup.GET("", authMiddleware.WithAdminNotRequired().Add(), uc.listApiKeysHandler) apiKeyGroup.GET("", uc.listApiKeysHandler)
apiKeyGroup.POST("", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.createApiKeyHandler) apiKeyGroup.POST("", uc.createApiKeyHandler)
apiKeyGroup.POST("/:id/renew", authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), uc.renewApiKeyHandler) apiKeyGroup.POST("/:id/renew", uc.renewApiKeyHandler)
apiKeyGroup.DELETE("/:id", authMiddleware.WithAdminNotRequired().Add(), uc.revokeApiKeyHandler) apiKeyGroup.DELETE("/:id", uc.revokeApiKeyHandler)
} }
} }

View File

@@ -2,9 +2,7 @@ package controller
import ( import (
"net/http" "net/http"
"slices"
"strconv" "strconv"
"strings"
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -36,7 +34,6 @@ func NewAppImagesController(
group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler) group.PUT("/application-images/favicon", authMiddleware.Add(), controller.updateFaviconHandler)
group.PUT("/application-images/default-profile-picture", authMiddleware.Add(), controller.updateDefaultProfilePicture) 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) group.DELETE("/application-images/default-profile-picture", authMiddleware.Add(), controller.deleteDefaultProfilePicture)
} }
@@ -195,27 +192,12 @@ func (c *AppImagesController) updateBackgroundImageHandler(ctx *gin.Context) {
ctx.Status(http.StatusNoContent) 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 // updateFaviconHandler godoc
// @Summary Update favicon // @Summary Update favicon
// @Description Update the application favicon // @Description Update the application favicon
// @Tags Application Images // @Tags Application Images
// @Accept multipart/form-data // @Accept multipart/form-data
// @Param file formData file true "Favicon file (.svg/.png/.ico)" // @Param file formData file true "Favicon file (.ico)"
// @Success 204 "No Content" // @Success 204 "No Content"
// @Router /api/application-images/favicon [put] // @Router /api/application-images/favicon [put]
func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) { func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
@@ -226,9 +208,8 @@ func (c *AppImagesController) updateFaviconHandler(ctx *gin.Context) {
} }
fileType := utils.GetFileExtension(file.Filename) fileType := utils.GetFileExtension(file.Filename)
mimeType := utils.GetImageMimeType(strings.ToLower(fileType)) if fileType != "ico" {
if !slices.Contains([]string{"image/svg+xml", "image/png", "image/x-icon"}, mimeType) { _ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".ico"})
_ = ctx.Error(&common.WrongFileTypeError{ExpectedFileType: ".svg or .png or .ico"})
return return
} }

View File

@@ -1,7 +1,6 @@
package controller package controller
import ( import (
"context"
"errors" "errors"
"log/slog" "log/slog"
"net/http" "net/http"
@@ -25,11 +24,7 @@ import (
// @Description Initializes all OIDC-related API endpoints for authentication and client management // @Description Initializes all OIDC-related API endpoints for authentication and client management
// @Tags OIDC // @Tags OIDC
func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) { func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, fileSizeLimitMiddleware *middleware.FileSizeLimitMiddleware, oidcService *service.OidcService, jwtService *service.JwtService) {
oc := &OidcController{ oc := &OidcController{oidcService: oidcService, jwtService: jwtService}
oidcService: oidcService,
jwtService: jwtService,
createTokens: oidcService.CreateTokens,
}
group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler) group.POST("/oidc/authorize", authMiddleware.WithAdminNotRequired().Add(), oc.authorizeHandler)
group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler) group.POST("/oidc/authorization-required", authMiddleware.WithAdminNotRequired().Add(), oc.authorizationConfirmationRequiredHandler)
@@ -52,7 +47,7 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler) group.POST("/oidc/clients/:id/secret", authMiddleware.Add(), oc.createClientSecretHandler)
group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler) group.GET("/oidc/clients/:id/logo", oc.getClientLogoHandler)
group.DELETE("/oidc/clients/:id/logo", authMiddleware.Add(), oc.deleteClientLogoHandler) group.DELETE("/oidc/clients/:id/logo", oc.deleteClientLogoHandler)
group.POST("/oidc/clients/:id/logo", authMiddleware.Add(), fileSizeLimitMiddleware.Add(2<<20), oc.updateClientLogoHandler) 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) group.GET("/oidc/clients/:id/preview/:userId", authMiddleware.Add(), oc.getClientPreviewHandler)
@@ -73,9 +68,8 @@ func NewOidcController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi
} }
type OidcController struct { type OidcController struct {
oidcService *service.OidcService oidcService *service.OidcService
jwtService *service.JwtService jwtService *service.JwtService
createTokens func(context.Context, dto.OidcCreateTokensDto) (service.CreatedTokens, error)
} }
// authorizeHandler godoc // authorizeHandler godoc
@@ -89,19 +83,12 @@ type OidcController struct {
// @Router /api/oidc/authorize [post] // @Router /api/oidc/authorize [post]
func (oc *OidcController) authorizeHandler(c *gin.Context) { func (oc *OidcController) authorizeHandler(c *gin.Context) {
var input dto.AuthorizeOidcClientRequestDto var input dto.AuthorizeOidcClientRequestDto
err := c.ShouldBindJSON(&input) if err := c.ShouldBindJSON(&input); err != nil {
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
code, callbackURL, err := oc.oidcService.Authorize( code, callbackURL, err := oc.oidcService.Authorize(c.Request.Context(), input, c.GetString("userID"), c.ClientIP(), c.Request.UserAgent())
c.Request.Context(),
input,
c.GetString("userID"),
c.ClientIP(),
c.Request.UserAgent(),
)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
@@ -157,13 +144,8 @@ func (oc *OidcController) authorizationConfirmationRequiredHandler(c *gin.Contex
// @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token" // @Success 200 {object} dto.OidcTokenResponseDto "Token response with access_token and optional id_token and refresh_token"
// @Router /api/oidc/token [post] // @Router /api/oidc/token [post]
func (oc *OidcController) createTokensHandler(c *gin.Context) { 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 var input dto.OidcCreateTokensDto
err := c.ShouldBind(&input) if err := c.ShouldBind(&input); err != nil {
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
@@ -182,10 +164,10 @@ func (oc *OidcController) createTokensHandler(c *gin.Context) {
// Client id and secret can also be passed over the Authorization header // Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" { if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request) input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
} }
tokens, err := oc.createTokens(c.Request.Context(), input) tokens, err := oc.oidcService.CreateTokens(c.Request.Context(), input)
switch { switch {
case errors.Is(err, &common.OidcAuthorizationPendingError{}): case errors.Is(err, &common.OidcAuthorizationPendingError{}):
@@ -340,15 +322,13 @@ func (oc *OidcController) introspectTokenHandler(c *gin.Context) {
creds service.ClientAuthCredentials creds service.ClientAuthCredentials
ok bool ok bool
) )
creds.ClientID, creds.ClientSecret, ok = utils.OAuthClientBasicAuth(c.Request) creds.ClientID, creds.ClientSecret, ok = c.Request.BasicAuth()
if !ok { if !ok {
// If there's no basic auth, check if we have a bearer token (used as client assertion) // If there's no basic auth, check if we have a bearer token
bearer, ok := utils.BearerAuth(c.Request) bearer, ok := utils.BearerAuth(c.Request)
if ok { if ok {
creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer creds.ClientAssertionType = service.ClientAssertionTypeJWTBearer
creds.ClientAssertion = bearer creds.ClientAssertion = bearer
// When using client assertions, client_id can be passed as a form field
creds.ClientID = input.ClientID
} }
} }
@@ -671,20 +651,15 @@ func (oc *OidcController) updateAllowedUserGroupsHandler(c *gin.Context) {
} }
func (oc *OidcController) deviceAuthorizationHandler(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 var input dto.OidcDeviceAuthorizationRequestDto
err := c.ShouldBind(&input) if err := c.ShouldBind(&input); err != nil {
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
// Client id and secret can also be passed over the Authorization header // Client id and secret can also be passed over the Authorization header
if input.ClientID == "" && input.ClientSecret == "" { if input.ClientID == "" && input.ClientSecret == "" {
input.ClientID, input.ClientSecret, _ = utils.OAuthClientBasicAuth(c.Request) input.ClientID, input.ClientSecret, _ = c.Request.BasicAuth()
} }
response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input) response, err := oc.oidcService.CreateDeviceAuthorization(c.Request.Context(), input)

View File

@@ -1,227 +0,0 @@
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)
})
}

View File

@@ -4,7 +4,6 @@ import (
"net/http" "net/http"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils/cookie" "github.com/pocket-id/pocket-id/backend/internal/utils/cookie"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -323,34 +322,22 @@ func (uc *UserController) updateCurrentUserProfilePictureHandler(c *gin.Context)
func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) { func (uc *UserController) createOneTimeAccessTokenHandler(c *gin.Context, own bool) {
var input dto.OneTimeAccessTokenCreateDto var input dto.OneTimeAccessTokenCreateDto
err := c.ShouldBindJSON(&input) if err := c.ShouldBindJSON(&input); err != nil {
if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return
} }
var ( var ttl time.Duration
userID string
ttl time.Duration
)
if own { if own {
// Get user ID from context and force the default TTL input.UserID = c.GetString("userID")
userID = c.GetString("userID")
ttl = defaultOneTimeAccessTokenDuration ttl = defaultOneTimeAccessTokenDuration
} else { } else {
// Get user ID from URL parameter, and optional TTL from body
userID = c.Param("id")
ttl = input.TTL.Duration ttl = input.TTL.Duration
if ttl <= 0 { if ttl <= 0 {
ttl = defaultOneTimeAccessTokenDuration ttl = defaultOneTimeAccessTokenDuration
} }
} }
if userID == "" { token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), input.UserID, ttl)
_ = c.Error(&common.UserIdNotProvidedError{})
return
}
token, err := uc.oneTimeAccessService.CreateOneTimeAccessToken(c.Request.Context(), userID, ttl)
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -5,17 +5,14 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "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/service"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )
// NewVersionController registers version-related routes. // NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, authMiddleware *middleware.AuthMiddleware, versionService *service.VersionService) { func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService} vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler) group.GET("/version/latest", vc.getLatestVersionHandler)
group.GET("/version/current", authMiddleware.WithAdminNotRequired().Add(), vc.getCurrentVersionHandler)
} }
type VersionController struct { type VersionController struct {
@@ -41,16 +38,3 @@ func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
"latestVersion": tag, "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,
})
}

View File

@@ -91,7 +91,6 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
"id_token_signing_alg_values_supported": []string{alg.String()}, "id_token_signing_alg_values_supported": []string{alg.String()},
"authorization_response_iss_parameter_supported": true, "authorization_response_iss_parameter_supported": true,
"code_challenge_methods_supported": []string{"plain", "S256"}, "code_challenge_methods_supported": []string{"plain", "S256"},
"token_endpoint_auth_methods_supported": []string{"client_secret_basic", "client_secret_post", "none"},
} }
return json.Marshal(config) return json.Marshal(config)
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
"github.com/pocket-id/pocket-id/backend/internal/utils"
) )
type sourceStruct struct { type sourceStruct struct {
@@ -59,11 +60,11 @@ type embeddedStruct struct {
func TestMapStruct(t *testing.T) { func TestMapStruct(t *testing.T) {
src := sourceStruct{ src := sourceStruct{
AString: "abcd", AString: "abcd",
AStringPtr: new("xyz"), AStringPtr: utils.Ptr("xyz"),
ABool: true, ABool: true,
ABoolPtr: new(false), ABoolPtr: utils.Ptr(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)), ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))), ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ANilStringPtr: nil, ANilStringPtr: nil,
ASlice: []string{"a", "b", "c"}, ASlice: []string{"a", "b", "c"},
AMap: map[string]int{ AMap: map[string]int{
@@ -79,8 +80,8 @@ func TestMapStruct(t *testing.T) {
Bar: 111, Bar: 111,
}, },
StringPtrToString: new("foobar"), StringPtrToString: utils.Ptr("foobar"),
EmptyStringPtrToString: new(""), EmptyStringPtrToString: utils.Ptr(""),
NilStringPtrToString: nil, NilStringPtrToString: nil,
IntToInt64: 99, IntToInt64: 99,
AuditLogEventToString: model.AuditLogEventAccountCreated, AuditLogEventToString: model.AuditLogEventAccountCreated,
@@ -117,11 +118,11 @@ func TestMapStructList(t *testing.T) {
sources := []sourceStruct{ sources := []sourceStruct{
{ {
AString: "first", AString: "first",
AStringPtr: new("one"), AStringPtr: utils.Ptr("one"),
ABool: true, ABool: true,
ABoolPtr: new(false), ABoolPtr: utils.Ptr(false),
ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)), ACustomDateTime: datatype.DateTime(time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))), ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC))),
ASlice: []string{"a", "b"}, ASlice: []string{"a", "b"},
AMap: map[string]int{ AMap: map[string]int{
"a": 1, "a": 1,
@@ -135,11 +136,11 @@ func TestMapStructList(t *testing.T) {
}, },
{ {
AString: "second", AString: "second",
AStringPtr: new("two"), AStringPtr: utils.Ptr("two"),
ABool: false, ABool: false,
ABoolPtr: new(true), ABoolPtr: utils.Ptr(true),
ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)), ACustomDateTime: datatype.DateTime(time.Date(2026, 6, 7, 8, 9, 10, 0, time.UTC)),
ACustomDateTimePtr: new(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))), ACustomDateTimePtr: utils.Ptr(datatype.DateTime(time.Date(2023, 6, 7, 8, 9, 10, 0, time.UTC))),
ASlice: []string{"c", "d", "e"}, ASlice: []string{"c", "d", "e"},
AMap: map[string]int{ AMap: map[string]int{
"c": 3, "c": 3,

View File

@@ -12,7 +12,7 @@ import (
// Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag. // Normalize iterates through an object and performs Unicode normalization on all string fields with the `unorm` tag.
func Normalize(obj any) { func Normalize(obj any) {
v := reflect.ValueOf(obj) v := reflect.ValueOf(obj)
if v.Kind() != reflect.Pointer || v.IsNil() { if v.Kind() != reflect.Ptr || v.IsNil() {
return return
} }
v = v.Elem() v = v.Elem()
@@ -21,7 +21,7 @@ func Normalize(obj any) {
if v.Kind() == reflect.Slice { if v.Kind() == reflect.Slice {
for i := 0; i < v.Len(); i++ { for i := 0; i < v.Len(); i++ {
elem := v.Index(i) elem := v.Index(i)
if elem.Kind() == reflect.Pointer && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct { if elem.Kind() == reflect.Ptr && !elem.IsNil() && elem.Elem().Kind() == reflect.Struct {
Normalize(elem.Interface()) Normalize(elem.Interface())
} else if elem.Kind() == reflect.Struct && elem.CanAddr() { } else if elem.Kind() == reflect.Struct && elem.CanAddr() {
Normalize(elem.Addr().Interface()) Normalize(elem.Addr().Interface())

View File

@@ -33,8 +33,8 @@ type OidcClientWithAllowedGroupsCountDto struct {
type OidcClientUpdateDto struct { type OidcClientUpdateDto struct {
Name string `json:"name" binding:"required,max=50" unorm:"nfc"` Name string `json:"name" binding:"required,max=50" unorm:"nfc"`
CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url_pattern"` CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url_pattern"` LogoutCallbackURLs []string `json:"logoutCallbackURLs" binding:"omitempty,dive,callback_url"`
IsPublic bool `json:"isPublic"` IsPublic bool `json:"isPublic"`
PkceEnabled bool `json:"pkceEnabled"` PkceEnabled bool `json:"pkceEnabled"`
RequiresReauthentication bool `json:"requiresReauthentication"` RequiresReauthentication bool `json:"requiresReauthentication"`
@@ -66,7 +66,7 @@ type OidcClientFederatedIdentityDto struct {
type AuthorizeOidcClientRequestDto struct { type AuthorizeOidcClientRequestDto struct {
ClientID string `json:"clientID" binding:"required"` ClientID string `json:"clientID" binding:"required"`
Scope string `json:"scope" binding:"required"` Scope string `json:"scope" binding:"required"`
CallbackURL string `json:"callbackURL" binding:"omitempty,callback_url"` CallbackURL string `json:"callbackURL"`
Nonce string `json:"nonce"` Nonce string `json:"nonce"`
CodeChallenge string `json:"codeChallenge"` CodeChallenge string `json:"codeChallenge"`
CodeChallengeMethod string `json:"codeChallengeMethod"` CodeChallengeMethod string `json:"codeChallengeMethod"`
@@ -98,8 +98,7 @@ type OidcCreateTokensDto struct {
} }
type OidcIntrospectDto struct { type OidcIntrospectDto struct {
Token string `form:"token" binding:"required"` Token string `form:"token" binding:"required"`
ClientID string `form:"client_id"`
} }
type OidcUpdateAllowedUserGroupsDto struct { type OidcUpdateAllowedUserGroupsDto struct {

View File

@@ -3,7 +3,8 @@ package dto
import "github.com/pocket-id/pocket-id/backend/internal/utils" import "github.com/pocket-id/pocket-id/backend/internal/utils"
type OneTimeAccessTokenCreateDto struct { type OneTimeAccessTokenCreateDto struct {
TTL utils.JSONDuration `json:"ttl" binding:"ttl"` UserID string `json:"userId"`
TTL utils.JSONDuration `json:"ttl" binding:"ttl"`
} }
type OneTimeAccessEmailAsUnauthenticatedUserDto struct { type OneTimeAccessEmailAsUnauthenticatedUserDto struct {

View File

@@ -67,7 +67,7 @@ type ScimResourceData struct {
type ScimResourceMeta struct { type ScimResourceMeta struct {
Location string `json:"location,omitempty"` Location string `json:"location,omitempty"`
ResourceType string `json:"resourceType,omitempty"` ResourceType string `json:"resourceType,omitempty"`
Created time.Time `json:"created"` Created time.Time `json:"created,omitempty"`
LastModified time.Time `json:"lastModified,omitempty"` LastModified time.Time `json:"lastModified,omitempty"`
Version string `json:"version,omitempty"` Version string `json:"version,omitempty"`
} }

View File

@@ -1,9 +1,9 @@
package dto package dto
type SignUpDto struct { type SignUpDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
Token string `json:"token"` Token string `json:"token"`
} }

View File

@@ -23,12 +23,12 @@ type UserDto struct {
} }
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=1,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"` Email *string `json:"email" binding:"omitempty,email" unorm:"nfc"`
EmailVerified bool `json:"emailVerified"` EmailVerified bool `json:"emailVerified"`
FirstName string `json:"firstName" binding:"max=50" unorm:"nfc"` FirstName string `json:"firstName" binding:"required,min=1,max=50" unorm:"nfc"`
LastName string `json:"lastName" binding:"max=50" unorm:"nfc"` LastName string `json:"lastName" binding:"max=50" unorm:"nfc"`
DisplayName string `json:"displayName" binding:"max=100" unorm:"nfc"` DisplayName string `json:"displayName" binding:"required,min=1,max=100" unorm:"nfc"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"` Locale *string `json:"locale"`
Disabled bool `json:"disabled"` Disabled bool `json:"disabled"`

View File

@@ -3,6 +3,7 @@ package dto
import ( import (
"testing" "testing"
"github.com/pocket-id/pocket-id/backend/internal/utils"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
@@ -16,7 +17,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "valid input", name: "valid input",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: new("test@example.com"), Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -26,37 +27,27 @@ func TestUserCreateDto_Validate(t *testing.T) {
{ {
name: "missing username", name: "missing username",
input: UserCreateDto{ input: UserCreateDto{
Email: new("test@example.com"), Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
}, },
wantErr: "Field validation for 'Username' failed on the 'required' tag", 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", name: "missing display name",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Email: utils.Ptr("test@example.com"),
Email: new("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
}, },
wantErr: "", wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
}, },
{ {
name: "username contains invalid characters", name: "username contains invalid characters",
input: UserCreateDto{ input: UserCreateDto{
Username: "test/ser", Username: "test/ser",
Email: new("test@example.com"), Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -67,7 +58,7 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "invalid email", name: "invalid email",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: new("not-an-email"), Email: utils.Ptr("not-an-email"),
FirstName: "John", FirstName: "John",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
@@ -78,18 +69,18 @@ func TestUserCreateDto_Validate(t *testing.T) {
name: "first name too short", name: "first name too short",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: new("test@example.com"), Email: utils.Ptr("test@example.com"),
FirstName: "", FirstName: "",
LastName: "Doe", LastName: "Doe",
DisplayName: "John Doe", DisplayName: "John Doe",
}, },
wantErr: "", wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
}, },
{ {
name: "last name too long", name: "last name too long",
input: UserCreateDto{ input: UserCreateDto{
Username: "testuser", Username: "testuser",
Email: new("test@example.com"), Email: utils.Ptr("test@example.com"),
FirstName: "John", FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz", LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe", DisplayName: "John Doe",

View File

@@ -15,44 +15,43 @@ import (
// [a-zA-Z0-9] : The username must start with an alphanumeric character // [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 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 // [a-zA-Z0-9]$ : The username must end with an alphanumeric character
// (...)? : 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 validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9]([a-zA-Z0-9_.@-]*[a-zA-Z0-9])?$")
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$") var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() { func init() {
engine := binding.Validator.Engine().(*validator.Validate) v := binding.Validator.Engine().(*validator.Validate)
// Maximum allowed value for TTLs // Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour const maxTTL = 31 * 24 * time.Hour
validators := map[string]validator.Func{ if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
"username": func(fl validator.FieldLevel) bool { return ValidateUsername(fl.Field().String())
return ValidateUsername(fl.Field().String()) }); err != nil {
}, panic("Failed to register custom validation for username: " + err.Error())
"client_id": func(fl validator.FieldLevel) bool {
return ValidateClientID(fl.Field().String())
},
"ttl": func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
}
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
},
"callback_url": func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
},
"callback_url_pattern": func(fl validator.FieldLevel) bool {
return ValidateCallbackURLPattern(fl.Field().String())
},
} }
for k, v := range validators {
err := engine.RegisterValidation(k, v) if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
if err != nil { return ValidateClientID(fl.Field().String())
panic("Failed to register custom validation for " + k + ": " + err.Error()) }); err != nil {
panic("Failed to register custom validation for client_id: " + err.Error())
}
if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok {
return false
} }
// Allow zero, which means the field wasn't set
return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}); err != nil {
panic("Failed to register custom validation for ttl: " + err.Error())
}
if err := v.RegisterValidation("callback_url", func(fl validator.FieldLevel) bool {
return ValidateCallbackURL(fl.Field().String())
}); err != nil {
panic("Failed to register custom validation for callback_url: " + err.Error())
} }
} }
@@ -66,24 +65,21 @@ func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID) return validateClientIDRegex.MatchString(clientID)
} }
// ValidateCallbackURL validates the input callback URL // ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(str string) bool { func ValidateCallbackURL(raw string) bool {
// Ensure the URL is a valid one and that the protocol is not "javascript:" or "data:" // Don't validate if it contains a wildcard
u, err := url.Parse(str) if strings.Contains(raw, "*") {
return true
}
u, err := url.Parse(raw)
if err != nil { if err != nil {
return false return false
} }
switch strings.ToLower(u.Scheme) { if !u.IsAbs() {
case "javascript", "data":
return false return false
default:
return true
} }
}
// ValidateCallbackURLPattern validates callback URL patterns, with support for wildcards return true
func ValidateCallbackURLPattern(raw string) bool {
err := utils.ValidateCallbackURLPattern(raw)
return err == nil
} }

View File

@@ -20,7 +20,6 @@ func TestValidateUsername(t *testing.T) {
{"starts with symbol", ".username", false}, {"starts with symbol", ".username", false},
{"ends with non-alphanumeric", "username-", false}, {"ends with non-alphanumeric", "username-", false},
{"contains space", "user name", false}, {"contains space", "user name", false},
{"valid single char", "a", true},
{"empty", "", false}, {"empty", "", false},
{"only special chars", "-._@", false}, {"only special chars", "-._@", false},
{"valid long", "a1234567890_b.c-d@e", true}, {"valid long", "a1234567890_b.c-d@e", true},
@@ -57,28 +56,3 @@ func TestValidateClientID(t *testing.T) {
}) })
} }
} }
func TestValidateCallbackURL(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid https URL", "https://example.com/callback", true},
{"valid loopback URL", "http://127.0.0.1:49813/callback", true},
{"empty scheme", "//127.0.0.1:49813/callback", true},
{"valid custom scheme", "pocketid://callback", true},
{"invalid malformed URL", "http://[::1", false},
{"invalid missing scheme separator", "://example.com/callback", false},
{"rejects javascript scheme", "javascript:alert(1)", false},
{"rejects mixed case javascript scheme", "JavaScript:alert(1)", false},
{"rejects data scheme", "data:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
{"rejects mixed case data scheme", "DaTa:text/html;base64,PGgxPkhlbGxvPC9oMT4=", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateCallbackURL(tt.input))
})
}
}

View File

@@ -28,7 +28,7 @@ func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service
appConfig: appConfig, appConfig: appConfig,
httpClient: httpClient, httpClient: httpClient,
} }
return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true}) return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, true)
} }
type AnalyticsJob struct { type AnalyticsJob struct {

View File

@@ -22,7 +22,7 @@ func (s *Scheduler) RegisterApiKeyExpiryJob(ctx context.Context, apiKeyService *
} }
// Send every day at midnight // Send every day at midnight
return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, service.RegisterJobOpts{}) return s.RegisterJob(ctx, "ExpiredApiKeyEmailJob", gocron.CronJob("0 0 * * *", false), jobs.checkAndNotifyExpiringApiKeys, false)
} }
func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error { func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) error {
@@ -42,11 +42,7 @@ func (j *ApiKeyEmailJobs) checkAndNotifyExpiringApiKeys(ctx context.Context) err
} }
err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key) err = j.apiKeyService.SendApiKeyExpiringSoonEmail(ctx, key)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.ErrorContext(ctx, "Failed to send expiring API key notification email", slog.String("key", key.ID), slog.Any("error", err))
slog.String("key", key.ID),
slog.String("user", key.User.ID),
slog.Any("error", err),
)
} }
} }
return nil return nil

View File

@@ -7,37 +7,28 @@ import (
"log/slog" "log/slog"
"time" "time"
backoff "github.com/cenkalti/backoff/v5" "github.com/go-co-op/gocron/v2"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/common" "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/model"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" 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 { func (s *Scheduler) RegisterDbCleanupJobs(ctx context.Context, db *gorm.DB) error {
jobs := &DbCleanupJobs{db: db} jobs := &DbCleanupJobs{db: db}
newBackOff := func() *backoff.ExponentialBackOff { // Run every 24 hours (but with some jitter so they don't run at the exact same time), and now
bo := backoff.NewExponentialBackOff() def := gocron.DurationRandomJob(24*time.Hour-2*time.Minute, 24*time.Hour+2*time.Minute)
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( return errors.Join(
s.RegisterJob(ctx, "ClearWebauthnSessions", jobDefWithJitter(24*time.Hour), jobs.clearWebauthnSessions, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearWebauthnSessions", def, jobs.clearWebauthnSessions, true),
s.RegisterJob(ctx, "ClearOneTimeAccessTokens", jobDefWithJitter(24*time.Hour), jobs.clearOneTimeAccessTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOneTimeAccessTokens", def, jobs.clearOneTimeAccessTokens, true),
s.RegisterJob(ctx, "ClearSignupTokens", jobDefWithJitter(24*time.Hour), jobs.clearSignupTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearSignupTokens", def, jobs.clearSignupTokens, true),
s.RegisterJob(ctx, "ClearEmailVerificationTokens", jobDefWithJitter(24*time.Hour), jobs.clearEmailVerificationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearEmailVerificationTokens", def, jobs.clearEmailVerificationTokens, true),
s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", jobDefWithJitter(24*time.Hour), jobs.clearOidcAuthorizationCodes, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOidcAuthorizationCodes", def, jobs.clearOidcAuthorizationCodes, true),
s.RegisterJob(ctx, "ClearOidcRefreshTokens", jobDefWithJitter(24*time.Hour), jobs.clearOidcRefreshTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearOidcRefreshTokens", def, jobs.clearOidcRefreshTokens, true),
s.RegisterJob(ctx, "ClearReauthenticationTokens", jobDefWithJitter(24*time.Hour), jobs.clearReauthenticationTokens, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearReauthenticationTokens", def, jobs.clearReauthenticationTokens, true),
s.RegisterJob(ctx, "ClearAuditLogs", jobDefWithJitter(24*time.Hour), jobs.clearAuditLogs, service.RegisterJobOpts{RunImmediately: true, BackOff: newBackOff()}), s.RegisterJob(ctx, "ClearAuditLogs", def, jobs.clearAuditLogs, true),
) )
} }

View File

@@ -13,26 +13,20 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/pocket-id/pocket-id/backend/internal/model" "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" "github.com/pocket-id/pocket-id/backend/internal/storage"
) )
func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error { func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error {
jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage} jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage}
var errs []error err := s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, false)
errs = append(errs,
s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}),
)
// Only necessary for file system storage // Only necessary for file system storage
if fileStorage.Type() == storage.TypeFileSystem { if fileStorage.Type() == storage.TypeFileSystem {
errs = append(errs, err = errors.Join(err, s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, true))
s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}),
)
} }
return errors.Join(errs...) return err
} }
type FileCleanupJobs struct { type FileCleanupJobs struct {
@@ -74,8 +68,7 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context)
// If these initials aren't used by any user, delete the file // If these initials aren't used by any user, delete the file
if _, ok := initialsInUse[initials]; !ok { if _, ok := initialsInUse[initials]; !ok {
filePath := path.Join(defaultPicturesDir, filename) filePath := path.Join(defaultPicturesDir, filename)
err = j.fileStorage.Delete(ctx, filePath) if err := j.fileStorage.Delete(ctx, filePath); err != nil {
if err != nil {
slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err)) slog.ErrorContext(ctx, "Failed to delete unused default profile picture", slog.String("path", filePath), slog.Any("error", err))
} else { } else {
filesDeleted++ filesDeleted++
@@ -102,9 +95,8 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error {
return nil return nil
} }
rErr := j.fileStorage.Delete(ctx, p.Path) if err := j.fileStorage.Delete(ctx, p.Path); err != nil {
if rErr != nil { slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", err))
slog.ErrorContext(ctx, "Failed to delete temp file", slog.String("path", p.Path), slog.Any("error", rErr))
return nil return nil
} }
deleted++ deleted++

View File

@@ -23,7 +23,7 @@ func (s *Scheduler) RegisterGeoLiteUpdateJobs(ctx context.Context, geoLiteServic
jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService} jobs := &GeoLiteUpdateJobs{geoLiteService: geoLiteService}
// Run every 24 hours (and right away) // Run every 24 hours (and right away)
return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, service.RegisterJobOpts{RunImmediately: true}) return s.RegisterJob(ctx, "UpdateGeoLiteDB", gocron.DurationJob(24*time.Hour), jobs.updateGoeLiteDB, true)
} }
func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error { func (j *GeoLiteUpdateJobs) updateGoeLiteDB(ctx context.Context) error {

View File

@@ -4,6 +4,8 @@ import (
"context" "context"
"time" "time"
"github.com/go-co-op/gocron/v2"
"github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/service"
) )
@@ -15,8 +17,8 @@ type LdapJobs struct {
func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error { func (s *Scheduler) RegisterLdapJobs(ctx context.Context, ldapService *service.LdapService, appConfigService *service.AppConfigService) error {
jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService} jobs := &LdapJobs{ldapService: ldapService, appConfigService: appConfigService}
// Register the job to run every hour (with some jitter) // Register the job to run every hour
return s.RegisterJob(ctx, "SyncLdap", jobDefWithJitter(time.Hour), jobs.syncLdap, service.RegisterJobOpts{RunImmediately: true}) return s.RegisterJob(ctx, "SyncLdap", gocron.DurationJob(time.Hour), jobs.syncLdap, true)
} }
func (j *LdapJobs) syncLdap(ctx context.Context) error { func (j *LdapJobs) syncLdap(ctx context.Context) error {

View File

@@ -5,13 +5,9 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"time"
backoff "github.com/cenkalti/backoff/v5"
"github.com/go-co-op/gocron/v2" "github.com/go-co-op/gocron/v2"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/internal/service"
) )
type Scheduler struct { type Scheduler struct {
@@ -37,12 +33,16 @@ func (s *Scheduler) RemoveJob(name string) error {
if job.Name() == name { if job.Name() == name {
err := s.scheduler.RemoveJob(job.ID()) err := s.scheduler.RemoveJob(job.ID())
if err != nil { if err != nil {
errs = append(errs, fmt.Errorf("failed to dequeue job %q with ID %q: %w", name, job.ID().String(), err)) errs = append(errs, fmt.Errorf("failed to unqueue job %q with ID %q: %w", name, job.ID().String(), err))
} }
} }
} }
return errors.Join(errs...) if len(errs) > 0 {
return errors.Join(errs...)
}
return nil
} }
// Run the scheduler. // Run the scheduler.
@@ -64,29 +64,7 @@ func (s *Scheduler) Run(ctx context.Context) error {
return nil return nil
} }
func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, jobFn func(ctx context.Context) error, opts service.RegisterJobOpts) error { func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.JobDefinition, job func(ctx context.Context) error, runImmediately bool, extraOptions ...gocron.JobOption) 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{ jobOptions := []gocron.JobOption{
gocron.WithContext(ctx), gocron.WithContext(ctx),
gocron.WithName(name), gocron.WithName(name),
@@ -113,13 +91,13 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
), ),
} }
if opts.RunImmediately { if runImmediately {
jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately())) jobOptions = append(jobOptions, gocron.JobOption(gocron.WithStartImmediately()))
} }
jobOptions = append(jobOptions, opts.ExtraOptions...) jobOptions = append(jobOptions, extraOptions...)
_, err := s.scheduler.NewJob(def, gocron.NewTask(jobFn), jobOptions...) _, err := s.scheduler.NewJob(def, gocron.NewTask(job), jobOptions...)
if err != nil { if err != nil {
return fmt.Errorf("failed to register job %q: %w", name, err) return fmt.Errorf("failed to register job %q: %w", name, err)
@@ -127,9 +105,3 @@ func (s *Scheduler) RegisterJob(ctx context.Context, name string, def gocron.Job
return nil return nil
} }
func jobDefWithJitter(interval time.Duration) gocron.JobDefinition {
const jitter = 5 * time.Minute
return gocron.DurationRandomJob(interval-jitter, interval+jitter)
}

View File

@@ -16,8 +16,8 @@ type ScimJobs struct {
func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error { func (s *Scheduler) RegisterScimJobs(ctx context.Context, scimService *service.ScimService) error {
jobs := &ScimJobs{scimService: scimService} jobs := &ScimJobs{scimService: scimService}
// Register the job to run every hour (with some jitter) // Register the job to run every hour
return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, service.RegisterJobOpts{RunImmediately: true}) return s.RegisterJob(ctx, "SyncScim", gocron.DurationJob(time.Hour), jobs.SyncScim, true)
} }
func (j *ScimJobs) SyncScim(ctx context.Context) error { func (j *ScimJobs) SyncScim(ctx context.Context) error {

View File

@@ -34,7 +34,7 @@ func (m *ApiKeyAuthMiddleware) Add(adminRequired bool) gin.HandlerFunc {
} }
func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) { func (m *ApiKeyAuthMiddleware) Verify(c *gin.Context, adminRequired bool) (userID string, isAdmin bool, err error) {
apiKey := c.GetHeader("X-API-Key") apiKey := c.GetHeader("X-API-KEY")
user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey) user, err := m.apiKeyService.ValidateApiKey(c.Request.Context(), apiKey)
if err != nil { if err != nil {

View File

@@ -18,7 +18,6 @@ type AuthMiddleware struct {
type AuthOptions struct { type AuthOptions struct {
AdminRequired bool AdminRequired bool
SuccessOptional bool SuccessOptional bool
AllowApiKeyAuth bool
} }
func NewAuthMiddleware( func NewAuthMiddleware(
@@ -32,7 +31,6 @@ func NewAuthMiddleware(
options: AuthOptions{ options: AuthOptions{
AdminRequired: true, AdminRequired: true,
SuccessOptional: false, SuccessOptional: false,
AllowApiKeyAuth: true,
}, },
} }
} }
@@ -61,17 +59,6 @@ func (m *AuthMiddleware) WithSuccessOptional() *AuthMiddleware {
return clone 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 { func (m *AuthMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) { return func(c *gin.Context) {
userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired) userID, isAdmin, err := m.jwtMiddleware.Verify(c, m.options.AdminRequired)
@@ -92,21 +79,6 @@ func (m *AuthMiddleware) Add() gin.HandlerFunc {
return 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 // JWT auth failed, try API key auth
userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired) userID, isAdmin, err = m.apiKeyMiddleware.Verify(c, m.options.AdminRequired)
if err == nil { if err == nil {

View File

@@ -1,104 +0,0 @@
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
}

View File

@@ -17,12 +17,12 @@ func NewRateLimitMiddleware() *RateLimitMiddleware {
} }
func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc {
if common.EnvConfig.DisableRateLimiting { if common.EnvConfig.DisableRateLimiting == true {
return func(c *gin.Context) { return func(c *gin.Context) {
c.Next() c.Next()
} }
} }
// Map to store the rate limiters per IP // Map to store the rate limiters per IP
var clients = make(map[string]*client) var clients = make(map[string]*client)
var mu sync.Mutex var mu sync.Mutex

View File

@@ -70,12 +70,13 @@ func TestAppConfigVariable_AsMinutesDuration(t *testing.T) {
// - dto.AppConfigDto should not include "internal" fields from model.AppConfig // - 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 // 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) { func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
appConfigType := reflect.TypeFor[model.AppConfig]() appConfigType := reflect.TypeOf(model.AppConfig{})
updateDtoType := reflect.TypeFor[dto.AppConfigUpdateDto]() updateDtoType := reflect.TypeOf(dto.AppConfigUpdateDto{})
// Process AppConfig fields // Process AppConfig fields
appConfigFields := make(map[string]string) appConfigFields := make(map[string]string)
for field := range appConfigType.Fields() { for i := 0; i < appConfigType.NumField(); i++ {
field := appConfigType.Field(i)
if field.Tag.Get("key") == "" { if field.Tag.Get("key") == "" {
// Skip internal fields // Skip internal fields
continue continue
@@ -90,7 +91,9 @@ func TestAppConfigStructMatchesUpdateDto(t *testing.T) {
// Process AppConfigUpdateDto fields // Process AppConfigUpdateDto fields
dtoFields := make(map[string]string) dtoFields := make(map[string]string)
for field := range updateDtoType.Fields() { for i := 0; i < updateDtoType.NumField(); i++ {
field := updateDtoType.Field(i)
// Extract the json name from the tag (takes the part before any binding constraints) // Extract the json name from the tag (takes the part before any binding constraints)
jsonTag := field.Tag.Get("json") jsonTag := field.Tag.Get("json")
jsonName, _, _ := strings.Cut(jsonTag, ",") jsonName, _, _ := strings.Cut(jsonTag, ",")

View File

@@ -39,7 +39,7 @@ func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" { if u.DisplayName != "" {
return u.DisplayName return u.DisplayName
} }
return u.FullName() return u.FirstName + " " + u.LastName
} }
func (u User) WebAuthnIcon() string { return "" } func (u User) WebAuthnIcon() string { return "" }
@@ -76,16 +76,7 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
} }
func (u User) FullName() string { func (u User) FullName() string {
fullname := strings.TrimSpace(u.FirstName + " " + u.LastName) return u.FirstName + " " + u.LastName
if fullname != "" {
return fullname
}
if u.DisplayName != "" {
return u.DisplayName
}
return u.Username
} }
func (u User) Initials() string { func (u User) Initials() string {

View File

@@ -58,7 +58,7 @@ type ReauthenticationToken struct {
type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck type AuthenticatorTransportList []protocol.AuthenticatorTransport //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type // Scan and Value methods for GORM to handle the custom type
func (atl *AuthenticatorTransportList) Scan(value any) error { func (atl *AuthenticatorTransportList) Scan(value interface{}) error {
return utils.UnmarshalJSONFromDatabase(atl, value) return utils.UnmarshalJSONFromDatabase(atl, value)
} }
@@ -69,7 +69,7 @@ func (atl AuthenticatorTransportList) Value() (driver.Value, error) {
type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck type CredentialParameters []protocol.CredentialParameter //nolint:recvcheck
// Scan and Value methods for GORM to handle the custom type // Scan and Value methods for GORM to handle the custom type
func (cp *CredentialParameters) Scan(value any) error { func (cp *CredentialParameters) Scan(value interface{}) error {
return utils.UnmarshalJSONFromDatabase(cp, value) return utils.UnmarshalJSONFromDatabase(cp, value)
} }

View File

@@ -3,7 +3,6 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"time" "time"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
@@ -17,25 +16,13 @@ import (
"gorm.io/gorm/clause" "gorm.io/gorm/clause"
) )
const staticApiKeyUserID = "00000000-0000-0000-0000-000000000000"
type ApiKeyService struct { type ApiKeyService struct {
db *gorm.DB db *gorm.DB
emailService *EmailService emailService *EmailService
} }
func NewApiKeyService(ctx context.Context, db *gorm.DB, emailService *EmailService) (*ApiKeyService, error) { func NewApiKeyService(db *gorm.DB, emailService *EmailService) *ApiKeyService {
s := &ApiKeyService{db: db, emailService: emailService} return &ApiKeyService{db: db, emailService: emailService}
if common.EnvConfig.StaticApiKey == "" {
err := s.deleteStaticApiKeyUser(ctx)
if err != nil {
return nil, err
}
}
return s, nil
} }
func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) { func (s *ApiKeyService) ListApiKeys(ctx context.Context, userID string, listRequestOptions utils.ListRequestOptions) ([]model.ApiKey, utils.PaginationResponse, error) {
@@ -78,9 +65,6 @@ func (s *ApiKeyService) CreateApiKey(ctx context.Context, userID string, input d
Create(&apiKey). Create(&apiKey).
Error Error
if err != nil { if err != nil {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return model.ApiKey{}, "", &common.AlreadyInUseError{Property: "API key name"}
}
return model.ApiKey{}, "", err return model.ApiKey{}, "", err
} }
@@ -160,10 +144,6 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
return model.User{}, &common.NoAPIKeyProvidedError{} return model.User{}, &common.NoAPIKeyProvidedError{}
} }
if common.EnvConfig.StaticApiKey != "" && apiKey == common.EnvConfig.StaticApiKey {
return s.initStaticApiKeyUser(ctx)
}
now := time.Now() now := time.Now()
hashedKey := utils.CreateSha256Hash(apiKey) hashedKey := utils.CreateSha256Hash(apiKey)
@@ -174,7 +154,7 @@ func (s *ApiKeyService) ValidateApiKey(ctx context.Context, apiKey string) (mode
Clauses(clause.Returning{}). Clauses(clause.Returning{}).
Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)). Where("key = ? AND expires_at > ?", hashedKey, datatype.DateTime(now)).
Updates(&model.ApiKey{ Updates(&model.ApiKey{
LastUsedAt: new(datatype.DateTime(now)), LastUsedAt: utils.Ptr(datatype.DateTime(now)),
}). }).
Preload("User"). Preload("User").
First(&key). First(&key).
@@ -206,75 +186,34 @@ func (s *ApiKeyService) ListExpiringApiKeys(ctx context.Context, daysAhead int)
} }
func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error { func (s *ApiKeyService) SendApiKeyExpiringSoonEmail(ctx context.Context, apiKey model.ApiKey) error {
if apiKey.User.Email == nil { 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 {
return &common.UserEmailNotSetError{} return &common.UserEmailNotSetError{}
} }
err := SendEmail(ctx, s.emailService, email.Address{ err := SendEmail(ctx, s.emailService, email.Address{
Name: apiKey.User.FullName(), Name: user.FullName(),
Email: *apiKey.User.Email, Email: *user.Email,
}, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{ }, ApiKeyExpiringSoonTemplate, &ApiKeyExpiringSoonTemplateData{
ApiKeyName: apiKey.Name, ApiKeyName: apiKey.Name,
ExpiresAt: apiKey.ExpiresAt.ToTime(), ExpiresAt: apiKey.ExpiresAt.ToTime(),
Name: apiKey.User.FirstName, Name: user.FirstName,
}) })
if err != nil { if err != nil {
return fmt.Errorf("error sending notification email: %w", err) return err
} }
// Mark the API key as having had an expiration email sent // Mark the API key as having had an expiration email sent
err = s.db.WithContext(ctx). return s.db.WithContext(ctx).
Model(&model.ApiKey{}). Model(&model.ApiKey{}).
Where("id = ?", apiKey.ID). Where("id = ?", apiKey.ID).
Update("expiration_email_sent", true). Update("expiration_email_sent", true).
Error 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) {
err = s.db.
WithContext(ctx).
First(&user, "id = ?", staticApiKeyUserID).
Error
if err == nil {
return user, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return model.User{}, err
}
usernameSuffix, err := utils.GenerateRandomAlphanumericString(6)
if err != nil {
return model.User{}, err
}
user = model.User{
Base: model.Base{
ID: staticApiKeyUserID,
},
FirstName: "Static API User",
Username: "static-api-user-" + usernameSuffix,
DisplayName: "Static API User",
IsAdmin: true,
}
err = s.db.
WithContext(ctx).
Create(&user).
Error
return user, err
}
func (s *ApiKeyService) deleteStaticApiKeyUser(ctx context.Context) error {
return s.db.
WithContext(ctx).
Delete(&model.User{}, "id = ?", staticApiKeyUserID).
Error
} }

View File

@@ -186,7 +186,8 @@ func (s *AppConfigService) UpdateAppConfig(ctx context.Context, input dto.AppCon
rt := reflect.ValueOf(input).Type() rt := reflect.ValueOf(input).Type()
rv := reflect.ValueOf(input) rv := reflect.ValueOf(input)
dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField()) dbUpdate := make([]model.AppConfigVariable, 0, rt.NumField())
for field := range rt.Fields() { for i := range rt.NumField() {
field := rt.Field(i)
value := rv.FieldByName(field.Name).String() value := rv.FieldByName(field.Name).String()
// Get the value of the json tag, taking only what's before the comma // Get the value of the json tag, taking only what's before the comma

View File

@@ -73,10 +73,7 @@ func (lv *lockValue) Unmarshal(raw string) error {
// Acquire obtains the lock. When force is true, the lock is stolen from any existing owner. // 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. // 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) { func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil time.Time, err error) {
tx := s.db.WithContext(ctx).Begin() tx := s.db.Begin()
if tx.Error != nil {
return time.Time{}, fmt.Errorf("begin lock transaction: %w", tx.Error)
}
defer func() { defer func() {
tx.Rollback() tx.Rollback()
}() }()
@@ -96,8 +93,7 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
var prevLock lockValue var prevLock lockValue
if prevLockRaw != "" { if prevLockRaw != "" {
err = prevLock.Unmarshal(prevLockRaw) if err := prevLock.Unmarshal(prevLockRaw); err != nil {
if err != nil {
return time.Time{}, fmt.Errorf("decode existing lock value: %w", err) return time.Time{}, fmt.Errorf("decode existing lock value: %w", err)
} }
} }
@@ -143,8 +139,7 @@ func (s *AppLockService) Acquire(ctx context.Context, force bool) (waitUntil tim
return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error) return time.Time{}, fmt.Errorf("lock acquisition failed: %w", res.Error)
} }
err = tx.Commit().Error if err := tx.Commit().Error; err != nil {
if err != nil {
return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err) return time.Time{}, fmt.Errorf("commit lock acquisition: %w", err)
} }
@@ -179,8 +174,7 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
case <-ctx.Done(): case <-ctx.Done():
return nil return nil
case <-ticker.C: case <-ticker.C:
err := s.renew(ctx) if err := s.renew(ctx); err != nil {
if err != nil {
return fmt.Errorf("renew lock: %w", err) return fmt.Errorf("renew lock: %w", err)
} }
} }
@@ -189,43 +183,33 @@ func (s *AppLockService) RunRenewal(ctx context.Context) error {
// Release releases the lock if it is held by this process. // Release releases the lock if it is held by this process.
func (s *AppLockService) Release(ctx context.Context) error { func (s *AppLockService) Release(ctx context.Context) error {
db, err := s.db.DB() opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
if err != nil { defer cancel()
return fmt.Errorf("failed to get DB connection: %w", err)
}
var query string var query string
switch s.db.Name() { switch s.db.Name() {
case "sqlite": case "sqlite":
query = ` query = `
DELETE FROM kv DELETE FROM kv
WHERE key = ? WHERE key = ?
AND json_extract(value, '$.lock_id') = ? AND json_extract(value, '$.lock_id') = ?
` `
case "postgres": case "postgres":
query = ` query = `
DELETE FROM kv DELETE FROM kv
WHERE key = $1 WHERE key = $1
AND value::json->>'lock_id' = $2 AND value::json->>'lock_id' = $2
` `
default: default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name()) 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, lockKey, s.lockID)
defer cancel() if res.Error != nil {
return fmt.Errorf("release lock failed: %w", res.Error)
res, err := db.ExecContext(opCtx, query, lockKey, s.lockID)
if err != nil {
return fmt.Errorf("release lock failed: %w", err)
} }
count, err := res.RowsAffected() if res.RowsAffected == 0 {
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.Warn("Application lock not held by this process, cannot release",
slog.Int64("process_id", s.processID), slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID), slog.String("host_id", s.hostID),
@@ -241,11 +225,6 @@ WHERE key = $1
// renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts). // renew tries to renew the lock, retrying up to renewRetries times (sleeping 1s between attempts).
func (s *AppLockService) renew(ctx context.Context) error { 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 var lastErr error
for attempt := 1; attempt <= renewRetries; attempt++ { for attempt := 1; attempt <= renewRetries; attempt++ {
now := time.Now() now := time.Now()
@@ -267,56 +246,42 @@ func (s *AppLockService) renew(ctx context.Context) error {
switch s.db.Name() { switch s.db.Name() {
case "sqlite": case "sqlite":
query = ` query = `
UPDATE kv UPDATE kv
SET value = ? SET value = ?
WHERE key = ? WHERE key = ?
AND json_extract(value, '$.lock_id') = ? AND json_extract(value, '$.lock_id') = ?
AND json_extract(value, '$.expires_at') > ? AND json_extract(value, '$.expires_at') > ?
` `
case "postgres": case "postgres":
query = ` query = `
UPDATE kv UPDATE kv
SET value = $1 SET value = $1
WHERE key = $2 WHERE key = $2
AND value::json->>'lock_id' = $3 AND value::json->>'lock_id' = $3
AND ((value::json->>'expires_at')::bigint > $4) AND ((value::json->>'expires_at')::bigint > $4)
` `
default: default:
return fmt.Errorf("unsupported database dialect: %s", s.db.Name()) return fmt.Errorf("unsupported database dialect: %s", s.db.Name())
} }
opCtx, cancel := context.WithTimeout(ctx, 3*time.Second) opCtx, cancel := context.WithTimeout(ctx, 3*time.Second)
res, err := db.ExecContext(opCtx, query, raw, lockKey, s.lockID, nowUnix) res := s.db.WithContext(opCtx).Exec(query, raw, lockKey, s.lockID, nowUnix)
cancel() cancel()
// Query succeeded, but may have updated 0 rows switch {
if err == nil { case res.Error != nil:
count, err := res.RowsAffected() lastErr = fmt.Errorf("lock renewal failed: %w", res.Error)
if err != nil { case res.RowsAffected == 0:
return fmt.Errorf("failed to count affected rows: %w", err) // Must be after checking res.Error
} return ErrLockLost
default:
// If no rows were updated, we lost the lock
if count == 0 {
return ErrLockLost
}
// All good
slog.Debug("Renewed application lock", slog.Debug("Renewed application lock",
slog.Int64("process_id", s.processID), slog.Int64("process_id", s.processID),
slog.String("host_id", s.hostID), slog.String("host_id", s.hostID),
slog.Duration("duration", time.Since(now)),
) )
return nil 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 // Wait before next attempt or cancel if context is done
if attempt < renewRetries { if attempt < renewRetries {
select { select {

View File

@@ -49,23 +49,6 @@ func readLockValue(t *testing.T, db *gorm.DB) lockValue {
return value 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) { func TestAppLockServiceAcquire(t *testing.T) {
t.Run("creates new lock when none exists", func(t *testing.T) { t.Run("creates new lock when none exists", func(t *testing.T) {
db := testutils.NewDatabaseForTest(t) db := testutils.NewDatabaseForTest(t)
@@ -116,66 +99,6 @@ func TestAppLockServiceAcquire(t *testing.T) {
require.Equal(t, service.hostID, stored.HostID) require.Equal(t, service.hostID, stored.HostID)
require.Greater(t, stored.ExpiresAt, time.Now().Unix()) 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) { func TestAppLockServiceRelease(t *testing.T) {
@@ -211,24 +134,6 @@ func TestAppLockServiceRelease(t *testing.T) {
stored := readLockValue(t, db) stored := readLockValue(t, db)
require.Equal(t, existing, stored) 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) { func TestAppLockServiceRenew(t *testing.T) {
@@ -281,21 +186,4 @@ func TestAppLockServiceRenew(t *testing.T) {
err = service.renew(context.Background()) err = service.renew(context.Background())
require.ErrorIs(t, err, ErrLockLost) 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)
})
} }

View File

@@ -81,7 +81,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
}, },
Username: "tim", Username: "tim",
Email: new("tim.cook@test.com"), Email: utils.Ptr("tim.cook@test.com"),
EmailVerified: true, EmailVerified: true,
FirstName: "Tim", FirstName: "Tim",
LastName: "Cook", LastName: "Cook",
@@ -93,7 +93,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
}, },
Username: "craig", Username: "craig",
Email: new("craig.federighi@test.com"), Email: utils.Ptr("craig.federighi@test.com"),
EmailVerified: false, EmailVerified: false,
FirstName: "Craig", FirstName: "Craig",
LastName: "Federighi", LastName: "Federighi",
@@ -105,7 +105,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c", ID: "d9256384-98ad-49a7-bc58-99ad0b4dc23c",
}, },
Username: "eddy", Username: "eddy",
Email: new("eddy.cue@test.com"), Email: utils.Ptr("eddy.cue@test.com"),
FirstName: "Eddy", FirstName: "Eddy",
LastName: "Cue", LastName: "Cue",
DisplayName: "Eddy Cue", DisplayName: "Eddy Cue",
@@ -171,12 +171,12 @@ func (s *TestService) SeedDatabase(baseURL string) error {
ID: "3654a746-35d4-4321-ac61-0bdcff2b4055", ID: "3654a746-35d4-4321-ac61-0bdcff2b4055",
}, },
Name: "Nextcloud", Name: "Nextcloud",
LaunchURL: new("https://nextcloud.local"), LaunchURL: utils.Ptr("https://nextcloud.local"),
Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY Secret: "$2a$10$9dypwot8nGuCjT6wQWWpJOckZfRprhe2EkwpKizxS/fpVHrOLEJHC", // w2mUeZISmEvIDMEDvpY0PnxQIpj1m3zY
CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"}, CallbackURLs: model.UrlList{"http://nextcloud/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"}, LogoutCallbackURLs: model.UrlList{"http://nextcloud/auth/logout/callback"},
ImageType: new("png"), ImageType: utils.StringPointer("png"),
CreatedByID: new(users[0].ID), CreatedByID: utils.Ptr(users[0].ID),
}, },
{ {
Base: model.Base{ Base: model.Base{
@@ -185,7 +185,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Immich", Name: "Immich",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://immich/auth/callback"}, CallbackURLs: model.UrlList{"http://immich/auth/callback"},
CreatedByID: new(users[1].ID), CreatedByID: utils.Ptr(users[1].ID),
IsGroupRestricted: true, IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{ AllowedUserGroups: []model.UserGroup{
userGroups[1], userGroups[1],
@@ -200,7 +200,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
CallbackURLs: model.UrlList{"http://tailscale/auth/callback"}, CallbackURLs: model.UrlList{"http://tailscale/auth/callback"},
LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"}, LogoutCallbackURLs: model.UrlList{"http://tailscale/auth/logout/callback"},
IsGroupRestricted: true, IsGroupRestricted: true,
CreatedByID: new(users[0].ID), CreatedByID: utils.Ptr(users[0].ID),
}, },
{ {
Base: model.Base{ Base: model.Base{
@@ -209,7 +209,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "Federated", Name: "Federated",
Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x Secret: "$2a$10$Ak.FP8riD1ssy2AGGbG.gOpnp/rBpymd74j0nxNMtW0GG1Lb4gzxe", // PYjrE9u4v9GVqXKi52eur0eb2Ci4kc0x
CallbackURLs: model.UrlList{"http://federated/auth/callback"}, CallbackURLs: model.UrlList{"http://federated/auth/callback"},
CreatedByID: new(users[1].ID), CreatedByID: utils.Ptr(users[1].ID),
AllowedUserGroups: []model.UserGroup{}, AllowedUserGroups: []model.UserGroup{},
Credentials: model.OidcClientCredentials{ Credentials: model.OidcClientCredentials{
FederatedIdentities: []model.OidcClientFederatedIdentity{ FederatedIdentities: []model.OidcClientFederatedIdentity{
@@ -229,7 +229,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Name: "SCIM Client", Name: "SCIM Client",
Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn Secret: "$2a$10$h4wfa8gI7zavDAxwzSq1sOwYU4e8DwK1XZ8ZweNnY5KzlJ3Iz.qdK", // nQbiuMRG7FpdK2EnDd5MBivWQeKFXohn
CallbackURLs: model.UrlList{"http://scimclient/auth/callback"}, CallbackURLs: model.UrlList{"http://scimclient/auth/callback"},
CreatedByID: new(users[0].ID), CreatedByID: utils.Ptr(users[0].ID),
IsGroupRestricted: true, IsGroupRestricted: true,
AllowedUserGroups: []model.UserGroup{ AllowedUserGroups: []model.UserGroup{
userGroups[0], userGroups[0],
@@ -258,7 +258,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Nonce: "nonce", Nonce: "nonce",
ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)), ExpiresAt: datatype.DateTime(time.Now().Add(1 * time.Hour)),
UserID: users[1].ID, UserID: users[1].ID,
ClientID: oidcClients[3].ID, ClientID: oidcClients[2].ID,
}, },
} }
for _, authCode := range authCodes { for _, authCode := range authCodes {
@@ -458,7 +458,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
{ {
Key: jwkutils.PrivateKeyDBKey, 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"} // {"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: 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=="), 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=="),
}, },
} }

View File

@@ -150,8 +150,7 @@ func SendEmail[V any](ctx context.Context, srv *EmailService, toEmail email.Addr
} }
// Send the email // Send the email
err = srv.sendEmailContent(client, toEmail, c) if err := srv.sendEmailContent(client, toEmail, c); err != nil {
if err != nil {
return fmt.Errorf("send email content: %w", err) return fmt.Errorf("send email content: %w", err)
} }

View File

@@ -129,39 +129,39 @@ func (s *ExportService) getScanValuesForTable(cols []string, types utils.DBSchem
case "boolean", "bool": case "boolean", "bool":
var x bool var x bool
if types[col].Nullable { if types[col].Nullable {
res[i] = new(new(x)) res[i] = utils.Ptr(utils.Ptr(x))
} else { } else {
res[i] = new(x) res[i] = utils.Ptr(x)
} }
case "blob", "bytea", "jsonb": case "blob", "bytea", "jsonb":
// Treat jsonb columns as binary too // Treat jsonb columns as binary too
var x []byte var x []byte
if types[col].Nullable { if types[col].Nullable {
res[i] = new(new(x)) res[i] = utils.Ptr(utils.Ptr(x))
} else { } else {
res[i] = new(x) res[i] = utils.Ptr(x)
} }
case "timestamp", "timestamptz", "timestamp with time zone", "datetime": case "timestamp", "timestamptz", "timestamp with time zone", "datetime":
var x datatype.DateTime var x datatype.DateTime
if types[col].Nullable { if types[col].Nullable {
res[i] = new(new(x)) res[i] = utils.Ptr(utils.Ptr(x))
} else { } else {
res[i] = new(x) res[i] = utils.Ptr(x)
} }
case "integer", "int", "bigint": case "integer", "int", "bigint":
var x int64 var x int64
if types[col].Nullable { if types[col].Nullable {
res[i] = new(new(x)) res[i] = utils.Ptr(utils.Ptr(x))
} else { } else {
res[i] = new(x) res[i] = utils.Ptr(x)
} }
default: default:
// Treat everything else as a string (including the "numeric" type) // Treat everything else as a string (including the "numeric" type)
var x string var x string
if types[col].Nullable { if types[col].Nullable {
res[i] = new(new(x)) res[i] = utils.Ptr(utils.Ptr(x))
} else { } else {
res[i] = new(x) res[i] = utils.Ptr(x)
} }
} }
} }

View File

@@ -2,7 +2,6 @@ package service
import ( import (
"archive/tar" "archive/tar"
"bytes"
"compress/gzip" "compress/gzip"
"context" "context"
"errors" "errors"
@@ -14,7 +13,6 @@ import (
"net/netip" "net/netip"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync" "sync"
"time" "time"
@@ -24,8 +22,6 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
) )
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
type GeoLiteService struct { type GeoLiteService struct {
httpClient *http.Client httpClient *http.Client
disableUpdater bool disableUpdater bool
@@ -113,11 +109,7 @@ func (s *GeoLiteService) UpdateDatabase(parentCtx context.Context) error {
} }
slog.Info("Updating GeoLite2 City database") 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) ctx, cancel := context.WithTimeout(parentCtx, 10*time.Minute)
defer cancel() defer cancel()
@@ -159,24 +151,7 @@ func (s *GeoLiteService) isDatabaseUpToDate() bool {
// extractDatabase extracts the database file from the tar.gz archive directly to the target location. // extractDatabase extracts the database file from the tar.gz archive directly to the target location.
func (s *GeoLiteService) extractDatabase(reader io.Reader) error { func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
// Check for gzip magic number gzr, err := gzip.NewReader(reader)
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 { if err != nil {
return fmt.Errorf("failed to create gzip reader: %w", err) return fmt.Errorf("failed to create gzip reader: %w", err)
} }
@@ -185,6 +160,7 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
tarReader := tar.NewReader(gzr) tarReader := tar.NewReader(gzr)
var totalSize int64 var totalSize int64
const maxTotalSize = 300 * 1024 * 1024 // 300 MB limit for total decompressed size
// Iterate over the files in the tar archive // Iterate over the files in the tar archive
for { for {
@@ -246,47 +222,3 @@ func (s *GeoLiteService) extractDatabase(reader io.Reader) error {
return errors.New("GeoLite2-City.mmdb not found in archive") 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
}

View File

@@ -7,7 +7,6 @@ import (
"fmt" "fmt"
"time" "time"
"github.com/google/uuid"
"github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwa"
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"github.com/lestrrat-go/jwx/v3/jwt" "github.com/lestrrat-go/jwx/v3/jwt"
@@ -194,7 +193,6 @@ func (s *JwtService) GenerateAccessToken(user model.User) (string, error) {
Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())). Expiration(now.Add(s.appConfigService.GetDbConfig().SessionDuration.AsDurationMinutes())).
IssuedAt(now). IssuedAt(now).
Issuer(s.envConfig.AppURL). Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).
Build() Build()
if err != nil { if err != nil {
return "", fmt.Errorf("failed to build token: %w", err) return "", fmt.Errorf("failed to build token: %w", err)
@@ -249,7 +247,6 @@ func (s *JwtService) BuildIDToken(userClaims map[string]any, clientID string, no
Expiration(now.Add(1 * time.Hour)). Expiration(now.Add(1 * time.Hour)).
IssuedAt(now). IssuedAt(now).
Issuer(s.envConfig.AppURL). Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).
Build() Build()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err) return nil, fmt.Errorf("failed to build token: %w", err)
@@ -339,7 +336,6 @@ func (s *JwtService) BuildOAuthAccessToken(user model.User, clientID string) (jw
Expiration(now.Add(1 * time.Hour)). Expiration(now.Add(1 * time.Hour)).
IssuedAt(now). IssuedAt(now).
Issuer(s.envConfig.AppURL). Issuer(s.envConfig.AppURL).
JwtID(uuid.New().String()).
Build() Build()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to build token: %w", err) return nil, fmt.Errorf("failed to build token: %w", err)

View File

@@ -20,14 +20,13 @@ import (
"github.com/pocket-id/pocket-id/backend/internal/common" "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/model"
"github.com/pocket-id/pocket-id/backend/internal/utils"
jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk" jwkutils "github.com/pocket-id/pocket-id/backend/internal/utils/jwk"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
) )
const testEncryptionKey = "0123456789abcdef0123456789abcdef" 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 { func newTestEnvConfig() *common.EnvConfigSchema {
return &common.EnvConfigSchema{ return &common.EnvConfigSchema{
AppURL: "https://test.example.com", AppURL: "https://test.example.com",
@@ -304,7 +303,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "user123"}, Base: model.Base{ID: "user123"},
Email: new("user@example.com"), Email: utils.Ptr("user@example.com"),
IsAdmin: false, IsAdmin: false,
} }
@@ -324,9 +323,6 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
audience, ok := claims.Audience() audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") && _ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{service.envConfig.AppURL}, audience, "Audience should contain the app URL") 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) expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration() expiration, ok := claims.Expiration()
@@ -340,7 +336,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
adminUser := model.User{ adminUser := model.User{
Base: model.Base{ID: "admin123"}, Base: model.Base{ID: "admin123"},
Email: new("admin@example.com"), Email: utils.Ptr("admin@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -392,7 +388,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "eddsauser123"}, Base: model.Base{ID: "eddsauser123"},
Email: new("eddsauser@example.com"), Email: utils.Ptr("eddsauser@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -429,7 +425,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "ecdsauser123"}, Base: model.Base{ID: "ecdsauser123"},
Email: new("ecdsauser@example.com"), Email: utils.Ptr("ecdsauser@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -466,7 +462,7 @@ func TestGenerateVerifyAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "rsauser123"}, Base: model.Base{ID: "rsauser123"},
Email: new("rsauser@example.com"), Email: utils.Ptr("rsauser@example.com"),
IsAdmin: true, IsAdmin: true,
} }
@@ -501,7 +497,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("generates and verifies ID token with standard claims", func(t *testing.T) { t.Run("generates and verifies ID token with standard claims", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig) service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "user123", "sub": "user123",
"name": "Test User", "name": "Test User",
"email": "user@example.com", "email": "user@example.com",
@@ -524,9 +520,6 @@ func TestGenerateVerifyIdToken(t *testing.T) {
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") 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) expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration() expiration, ok := claims.Expiration()
@@ -538,7 +531,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("can accept expired tokens if told so", func(t *testing.T) { t.Run("can accept expired tokens if told so", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig) service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "user123", "sub": "user123",
"name": "Test User", "name": "Test User",
"email": "user@example.com", "email": "user@example.com",
@@ -586,7 +579,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("generates and verifies ID token with nonce", func(t *testing.T) { t.Run("generates and verifies ID token with nonce", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig) service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "user456", "sub": "user456",
"name": "Another User", "name": "Another User",
} }
@@ -611,7 +604,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
t.Run("fails verification with incorrect issuer", func(t *testing.T) { t.Run("fails verification with incorrect issuer", func(t *testing.T) {
service, _, _ := setupJwtService(t, mockConfig) service, _, _ := setupJwtService(t, mockConfig)
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "user789", "sub": "user789",
} }
tokenString, err := service.GenerateIDToken(userClaims, "client-789", "") tokenString, err := service.GenerateIDToken(userClaims, "client-789", "")
@@ -633,7 +626,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.True(t, ok) require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "eddsauser456", "sub": "eddsauser456",
"name": "EdDSA User", "name": "EdDSA User",
"email": "eddsauser@example.com", "email": "eddsauser@example.com",
@@ -671,7 +664,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.True(t, ok) require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "ecdsauser456", "sub": "ecdsauser456",
"email": "ecdsauser@example.com", "email": "ecdsauser@example.com",
} }
@@ -708,7 +701,7 @@ func TestGenerateVerifyIdToken(t *testing.T) {
require.True(t, ok) require.True(t, ok)
assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original") assert.Equal(t, origKeyID, loadedKeyID, "Loaded key should have the same ID as the original")
userClaims := map[string]any{ userClaims := map[string]interface{}{
"sub": "rsauser456", "sub": "rsauser456",
"name": "RSA User", "name": "RSA User",
"email": "rsauser@example.com", "email": "rsauser@example.com",
@@ -741,7 +734,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "user123"}, Base: model.Base{ID: "user123"},
Email: new("user@example.com"), Email: utils.Ptr("user@example.com"),
} }
const clientID = "test-client-123" const clientID = "test-client-123"
@@ -761,9 +754,6 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
issuer, ok := claims.Issuer() issuer, ok := claims.Issuer()
_ = assert.True(t, ok, "Issuer not found in token") && _ = assert.True(t, ok, "Issuer not found in token") &&
assert.Equal(t, service.envConfig.AppURL, issuer, "Issuer should match app URL") 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) expectedExp := time.Now().Add(1 * time.Hour)
expiration, ok := claims.Expiration() expiration, ok := claims.Expiration()
@@ -824,7 +814,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "eddsauser789"}, Base: model.Base{ID: "eddsauser789"},
Email: new("eddsaoauth@example.com"), Email: utils.Ptr("eddsaoauth@example.com"),
} }
const clientID = "eddsa-oauth-client" const clientID = "eddsa-oauth-client"
@@ -861,7 +851,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "ecdsauser789"}, Base: model.Base{ID: "ecdsauser789"},
Email: new("ecdsaoauth@example.com"), Email: utils.Ptr("ecdsaoauth@example.com"),
} }
const clientID = "ecdsa-oauth-client" const clientID = "ecdsa-oauth-client"
@@ -898,7 +888,7 @@ func TestGenerateVerifyOAuthAccessToken(t *testing.T) {
user := model.User{ user := model.User{
Base: model.Base{ID: "rsauser789"}, Base: model.Base{ID: "rsauser789"},
Email: new("rsaoauth@example.com"), Email: utils.Ptr("rsaoauth@example.com"),
} }
const clientID = "rsa-oauth-client" const clientID = "rsa-oauth-client"

View File

@@ -35,7 +35,6 @@ type LdapService struct {
userService *UserService userService *UserService
groupService *UserGroupService groupService *UserGroupService
fileStorage storage.FileStorage fileStorage storage.FileStorage
clientFactory func() (ldapClient, error)
} }
type savePicture struct { type savePicture struct {
@@ -44,33 +43,8 @@ type savePicture struct {
picture string 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 { func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppConfigService, userService *UserService, groupService *UserGroupService, fileStorage storage.FileStorage) *LdapService {
service := &LdapService{ return &LdapService{
db: db, db: db,
httpClient: httpClient, httpClient: httpClient,
appConfigService: appConfigService, appConfigService: appConfigService,
@@ -78,12 +52,9 @@ func NewLdapService(db *gorm.DB, httpClient *http.Client, appConfigService *AppC
groupService: groupService, groupService: groupService,
fileStorage: fileStorage, fileStorage: fileStorage,
} }
service.clientFactory = service.createClient
return service
} }
func (s *LdapService) createClient() (ldapClient, error) { func (s *LdapService) createClient() (*ldap.Conn, error) {
dbConfig := s.appConfigService.GetDbConfig() dbConfig := s.appConfigService.GetDbConfig()
if !dbConfig.LdapEnabled.IsTrue() { if !dbConfig.LdapEnabled.IsTrue() {
@@ -108,33 +79,24 @@ func (s *LdapService) createClient() (ldapClient, error) {
func (s *LdapService) SyncAll(ctx context.Context) error { func (s *LdapService) SyncAll(ctx context.Context) error {
// Setup LDAP connection // Setup LDAP connection
client, err := s.clientFactory() client, err := s.createClient()
if err != nil { if err != nil {
return fmt.Errorf("failed to create LDAP client: %w", err) return fmt.Errorf("failed to create LDAP client: %w", err)
} }
defer client.Close() defer client.Close()
// 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)
}
// Start a transaction // Start a transaction
tx := s.db.WithContext(ctx).Begin() tx := s.db.Begin()
if tx.Error != nil { defer func() {
return fmt.Errorf("failed to begin database transaction: %w", tx.Error) tx.Rollback()
} }()
defer tx.Rollback()
// Reconcile users savePictures, deleteFiles, err := s.SyncUsers(ctx, tx, client)
savePictures, deleteFiles, err := s.reconcileUsers(ctx, tx, desiredState.users, desiredState.userIDs)
if err != nil { if err != nil {
return fmt.Errorf("failed to sync users: %w", err) return fmt.Errorf("failed to sync users: %w", err)
} }
// Reconcile groups err = s.SyncGroups(ctx, tx, client)
err = s.reconcileGroups(ctx, tx, desiredState.groups, desiredState.groupIDs)
if err != nil { if err != nil {
return fmt.Errorf("failed to sync groups: %w", err) return fmt.Errorf("failed to sync groups: %w", err)
} }
@@ -167,59 +129,10 @@ func (s *LdapService) SyncAll(ctx context.Context) error {
return nil return nil
} }
func (s *LdapService) fetchDesiredState(ctx context.Context, client ldapClient) (ldapDesiredState, error) { //nolint:gocognit
// Fetch users first so we can use their DNs when resolving group members func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.Conn) error {
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() dbConfig := s.appConfigService.GetDbConfig()
// Query LDAP for all groups we want to manage
searchAttrs := []string{ searchAttrs := []string{
dbConfig.LdapAttributeGroupName.Value, dbConfig.LdapAttributeGroupName.Value,
dbConfig.LdapAttributeGroupUniqueIdentifier.Value, dbConfig.LdapAttributeGroupUniqueIdentifier.Value,
@@ -236,42 +149,90 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
) )
result, err := client.Search(searchReq) result, err := client.Search(searchReq)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("failed to query LDAP groups: %w", err) return fmt.Errorf("failed to query LDAP: %w", err)
} }
// Build the in-memory desired state for groups // Create a mapping for groups that exist
ldapGroupIDs = make(map[string]struct{}, len(result.Entries)) ldapGroupIDs := make(map[string]struct{}, len(result.Entries))
desiredGroups = make([]ldapDesiredGroup, 0, len(result.Entries))
for _, value := range 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 // 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)) slog.Warn("Skipping LDAP group without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeGroupUniqueIdentifier.Value))
continue continue
} }
ldapGroupIDs[ldapID] = struct{}{} 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)
}
// Get group members and add to the correct Group // Get group members and add to the correct Group
groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value) groupMembers := value.GetAttributeValues(dbConfig.LdapAttributeGroupMember.Value)
memberUsernames := make([]string, 0, len(groupMembers)) membersUserId := make([]string, 0, len(groupMembers))
for _, member := range groupMembers { for _, member := range groupMembers {
username := s.resolveGroupMemberUsername(ctx, client, member, usernamesByDN) username := getDNProperty(dbConfig.LdapAttributeUserUsername.Value, member)
// If username extraction fails, try to query LDAP directly for the user
if username == "" { if username == "" {
continue // 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
}
} }
memberUsernames = append(memberUsernames, username) 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)
} }
syncGroup := dto.UserGroupCreateDto{ syncGroup := dto.UserGroupCreateDto{
Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), Name: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value), FriendlyName: value.GetAttributeValue(dbConfig.LdapAttributeGroupName.Value),
LdapID: ldapID, LdapID: ldapId,
} }
dto.Normalize(&syncGroup) dto.Normalize(syncGroup)
err = syncGroup.Validate() err = syncGroup.Validate()
if err != nil { if err != nil {
@@ -279,21 +240,66 @@ func (s *LdapService) fetchGroupsFromLDAP(ctx context.Context, client ldapClient
continue continue
} }
desiredGroups = append(desiredGroups, ldapDesiredGroup{ if databaseGroup.ID == "" {
ldapID: ldapID, newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
input: syncGroup, if err != nil {
memberUsernames: memberUsernames, 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)
}
}
} }
return desiredGroups, ldapGroupIDs, nil // 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
} }
func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient) (desiredUsers []ldapDesiredUser, ldapUserIDs map[string]struct{}, usernamesByDN map[string]string, err error) { //nolint:gocognit
func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.Conn) (savePictures []savePicture, deleteFiles []string, err error) {
dbConfig := s.appConfigService.GetDbConfig() dbConfig := s.appConfigService.GetDbConfig()
// Query LDAP for all users we want to manage
searchAttrs := []string{ searchAttrs := []string{
"memberOf",
"sn", "sn",
"cn", "cn",
dbConfig.LdapAttributeUserUniqueIdentifier.Value, dbConfig.LdapAttributeUserUniqueIdentifier.Value,
@@ -317,29 +323,59 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
result, err := client.Search(searchReq) result, err := client.Search(searchReq)
if err != nil { if err != nil {
return nil, nil, nil, fmt.Errorf("failed to query LDAP users: %w", err) return nil, nil, fmt.Errorf("failed to query LDAP: %w", err)
} }
// Build the in-memory desired state for users and a DN lookup for group membership resolution // Create a mapping for users that exist
ldapUserIDs = make(map[string]struct{}, len(result.Entries)) ldapUserIDs := make(map[string]struct{}, len(result.Entries))
usernamesByDN = make(map[string]string, len(result.Entries)) savePictures = make([]savePicture, 0, len(result.Entries))
desiredUsers = make([]ldapDesiredUser, 0, len(result.Entries))
for _, value := range result.Entries { for _, value := range result.Entries {
username := norm.NFC.String(value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value)) ldapId := convertLdapIdToString(value.GetAttributeValue(dbConfig.LdapAttributeUserUniqueIdentifier.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 // 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)) slog.Warn("Skipping LDAP user without a valid unique identifier", slog.String("attribute", dbConfig.LdapAttributeUserUniqueIdentifier.Value))
continue continue
} }
ldapUserIDs[ldapID] = struct{}{} 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
}
}
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
@@ -348,17 +384,15 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value), DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
// Admin status is computed after groups are loaded so it can use the IsAdmin: isAdmin,
// configured group member attribute instead of a hard-coded memberOf. LdapID: ldapId,
IsAdmin: false,
LdapID: ldapID,
} }
if newUser.DisplayName == "" { if newUser.DisplayName == "" {
newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName) newUser.DisplayName = strings.TrimSpace(newUser.FirstName + " " + newUser.LastName)
} }
dto.Normalize(&newUser) dto.Normalize(newUser)
err = newUser.Validate() err = newUser.Validate()
if err != nil { if err != nil {
@@ -366,201 +400,53 @@ func (s *LdapService) fetchUsersFromLDAP(ctx context.Context, client ldapClient)
continue continue
} }
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
}
memberUserIDs = append(memberUserIDs, databaseUser.ID)
}
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)
}
}
// 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)
}
// 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 userID := databaseUser.ID
if databaseUser.ID == "" { if databaseUser.ID == "" {
createdUser, err := s.userService.createUserInternal(ctx, desiredUser.input, true, tx) createdUser, err := s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) { if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping creating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) slog.Warn("Skipping creating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue continue
} else if err != nil { } else if err != nil {
return nil, nil, fmt.Errorf("error creating user '%s': %w", desiredUser.input.Username, err) return nil, nil, fmt.Errorf("error creating user '%s': %w", newUser.Username, err)
} }
userID = createdUser.ID userID = createdUser.ID
ldapUsersByID[desiredUser.ldapID] = createdUser
} else { } else {
_, err = s.userService.updateUserInternal(ctx, databaseUser.ID, desiredUser.input, false, true, tx) _, err = s.userService.updateUserInternal(ctx, databaseUser.ID, newUser, false, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) { if errors.Is(err, &common.AlreadyInUseError{}) {
slog.Warn("Skipping updating LDAP user", slog.String("username", desiredUser.input.Username), slog.Any("error", err)) slog.Warn("Skipping updating LDAP user", slog.String("username", newUser.Username), slog.Any("error", err))
continue continue
} else if err != nil { } else if err != nil {
return nil, nil, fmt.Errorf("error updating user '%s': %w", desiredUser.input.Username, err) return nil, nil, fmt.Errorf("error updating user '%s': %w", newUser.Username, err)
} }
} }
if desiredUser.picture != "" { // Save profile picture
pictureString := value.GetAttributeValue(dbConfig.LdapAttributeUserProfilePicture.Value)
if pictureString != "" {
// Storage operations must be executed outside of a transaction
savePictures = append(savePictures, savePicture{ savePictures = append(savePictures, savePicture{
userID: userID, userID: databaseUser.ID,
username: desiredUser.input.Username, username: userID,
picture: desiredUser.picture, picture: pictureString,
}) })
} }
} }
// Disable or delete users that are no longer present in LDAP // Get all LDAP users from the database
deleteFiles = make([]string, 0, len(ldapUsersInDB)) var ldapUsersInDb []model.User
for _, user := range ldapUsersInDB { err = tx.
if user.LdapID == nil { WithContext(ctx).
continue Find(&ldapUsersInDb, "ldap_id IS NOT NULL").
} Select("id, username, ldap_id, disabled").
Error
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
if _, exists := ldapUserIDs[*user.LdapID]; exists { if _, exists := ldapUserIDs[*user.LdapID]; exists {
continue continue
} }
@@ -572,73 +458,29 @@ func (s *LdapService) reconcileUsers(ctx context.Context, tx *gorm.DB, desiredUs
} }
slog.Info("Disabled user", slog.String("username", user.Username)) slog.Info("Disabled user", slog.String("username", user.Username))
continue } else {
} err = s.userService.deleteUserInternal(ctx, tx, user.ID, true)
if err != nil {
err = s.userService.deleteUserInternal(ctx, tx, user.ID, true) target := &common.LdapUserUpdateError{}
if err != nil { if errors.As(err, &target) {
target := &common.LdapUserUpdateError{} return nil, nil, fmt.Errorf("failed to delete user %s: LDAP user must be disabled before deletion", user.Username)
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)
} }
return nil, nil, fmt.Errorf("failed to delete user %s: %w", user.Username, err)
}
slog.Info("Deleted user", slog.String("username", user.Username)) slog.Info("Deleted user", slog.String("username", user.Username))
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
// Storage operations must be executed outside of a transaction
deleteFiles = append(deleteFiles, path.Join("profile-pictures", user.ID+".png"))
}
} }
return savePictures, deleteFiles, nil 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 { func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId string, pictureString string) error {
var reader io.ReadSeeker var reader io.ReadSeeker
// Accept either a URL, a base64-encoded payload, or raw binary data
_, err := url.ParseRequestURI(pictureString) _, err := url.ParseRequestURI(pictureString)
if err == nil { if err == nil {
ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second) ctx, cancel := context.WithTimeout(parentCtx, 15*time.Second)
@@ -680,31 +522,6 @@ func (s *LdapService) saveProfilePicture(parentCtx context.Context, userId strin
return nil 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 // 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 // See: https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/distinguished-names
func getDNProperty(property string, str string) string { func getDNProperty(property string, str string) string {
@@ -712,7 +529,7 @@ func getDNProperty(property string, str string) string {
// First we split at the comma // First we split at the comma
property = strings.ToLower(property) property = strings.ToLower(property)
l := len(property) + 1 l := len(property) + 1
for v := range strings.SplitSeq(str, ",") { for _, v := range strings.Split(str, ",") {
v = strings.TrimSpace(v) v = strings.TrimSpace(v)
if len(v) > l && strings.ToLower(v)[0:l] == property+"=" { if len(v) > l && strings.ToLower(v)[0:l] == property+"=" {
return v[l:] return v[l:]

View File

@@ -1,368 +1,9 @@
package service package service
import ( import (
"net/http"
"testing" "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) { func TestGetDNProperty(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -423,58 +64,10 @@ func TestGetDNProperty(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
result := getDNProperty(tt.property, tt.dn) result := getDNProperty(tt.property, tt.dn)
assert.Equalf(t, tt.expectedResult, result, "getDNProperty(%q, %q)", tt.property, tt.dn) if result != tt.expectedResult {
}) t.Errorf("getDNProperty(%q, %q) = %q, want %q",
} tt.property, tt.dn, result, tt.expectedResult)
} }
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)
}) })
} }
} }
@@ -505,7 +98,9 @@ func TestConvertLdapIdToString(t *testing.T) {
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
got := convertLdapIdToString(tt.input) got := convertLdapIdToString(tt.input)
assert.Equal(t, tt.expected, got) if got != tt.expected {
t.Errorf("Expected %q, got %q", tt.expected, got)
}
}) })
} }
} }

View File

@@ -125,7 +125,9 @@ func (s *OidcService) getJWKCache(ctx context.Context) (*jwk.Cache, error) {
func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) { func (s *OidcService) Authorize(ctx context.Context, input dto.AuthorizeOidcClientRequestDto, userID, ipAddress, userAgent string) (string, string, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer tx.Rollback() defer func() {
tx.Rollback()
}()
var client model.OidcClient var client model.OidcClient
err := tx. err := tx.
@@ -402,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{} return CreatedTokens{}, &common.OidcInvalidAuthorizationCodeError{}
} }
@@ -729,7 +731,7 @@ func (s *OidcService) CreateClient(ctx context.Context, input dto.OidcClientCrea
Base: model.Base{ Base: model.Base{
ID: input.ID, ID: input.ID,
}, },
CreatedByID: new(userID), CreatedByID: utils.Ptr(userID),
} }
updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto) updateOIDCClientModelFromDto(&client, &input.OidcClientUpdateDto)
@@ -1642,19 +1644,34 @@ 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) { func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *gorm.DB, input ClientAuthCredentials, allowPublicClientsWithoutAuth bool) (client *model.OidcClient, err error) {
if input.ClientID == "" { 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:
return nil, &common.OidcMissingClientCredentialsError{} return nil, &common.OidcMissingClientCredentialsError{}
} }
// Load the OIDC client's configuration // Load the OIDC client's configuration
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
First(&client, "id = ?", input.ClientID). First(&client, "id = ?", clientID).
Error Error
if errors.Is(err, gorm.ErrRecordNotFound) { if err != nil {
slog.WarnContext(ctx, "Client not found", slog.String("client", input.ClientID)) if errors.Is(err, gorm.ErrRecordNotFound) && isClientAssertion {
return nil, &common.OidcClientNotFoundError{} return nil, &common.OidcClientAssertionInvalidError{}
} else if err != nil { }
return nil, err return nil, err
} }
@@ -1669,7 +1686,7 @@ func (s *OidcService) verifyClientCredentialsInternal(ctx context.Context, tx *g
return client, nil return client, nil
// Next, check if we want to use client assertions from federated identities // Next, check if we want to use client assertions from federated identities
case input.ClientAssertionType == ClientAssertionTypeJWTBearer && input.ClientAssertion != "": case isClientAssertion:
err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input) err = s.verifyClientAssertionFromFederatedIdentities(ctx, client, input)
if err != nil { if err != nil {
slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err)) slog.WarnContext(ctx, "Invalid assertion for client", slog.String("client", client.ID), slog.Any("error", err))
@@ -1766,20 +1783,36 @@ func (s *OidcService) verifyClientAssertionFromFederatedIdentities(ctx context.C
// (Note: we don't use jwt.WithIssuer() because that would be redundant) // (Note: we don't use jwt.WithIssuer() because that would be redundant)
_, err = jwt.Parse(assertion, _, err = jwt.Parse(assertion,
jwt.WithValidate(true), jwt.WithValidate(true),
jwt.WithAcceptableSkew(clockSkew), jwt.WithAcceptableSkew(clockSkew),
jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)), jwt.WithKeySet(jwks, jws.WithInferAlgorithmFromKey(true), jws.WithUseDefault(true)),
jwt.WithAudience(audience), jwt.WithAudience(audience),
jwt.WithSubject(subject), jwt.WithSubject(subject),
) )
if err != nil { if err != nil {
return fmt.Errorf("client assertion could not be verified: %w", err) return fmt.Errorf("client assertion is not valid: %w", err)
} }
// If we're here, the assertion is valid // If we're here, the assertion is valid
return nil return nil
} }
// extractClientIDFromAssertion extracts the client_id from the JWT assertion's 'sub' claim
func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, error) {
// Parse the JWT without verification first to get the claims
insecureToken, err := jwt.ParseInsecure([]byte(assertion))
if err != nil {
return "", fmt.Errorf("failed to parse JWT assertion: %w", err)
}
// Extract the subject claim which must be the client_id according to RFC 7523
sub, ok := insecureToken.Subject()
if !ok || sub == "" {
return "", fmt.Errorf("missing or invalid 'sub' claim in JWT assertion")
}
return sub, nil
}
func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) { func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, userID string, scopes []string) (*dto.OidcClientPreviewDto, error) {
tx := s.db.Begin() tx := s.db.Begin()
defer func() { defer func() {
@@ -1867,7 +1900,7 @@ func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scope
claims["sub"] = user.ID claims["sub"] = user.ID
if slices.Contains(scopes, "email") { if slices.Contains(scopes, "email") {
claims["email"] = user.Email claims["email"] = user.Email
claims["email_verified"] = user.EmailVerified claims["email_verified"] = s.appConfigService.GetDbConfig().EmailsVerified.IsTrue()
} }
if slices.Contains(scopes, "groups") { if slices.Contains(scopes, "groups") {

View File

@@ -229,12 +229,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
Subject: federatedClient.ID, Subject: federatedClient.ID,
JWKS: federatedClientIssuer + "/jwks.json", JWKS: federatedClientIssuer + "/jwks.json",
}, },
{
Issuer: "federated-issuer-2",
Audience: federatedClientAudience,
Subject: "my-federated-client",
JWKS: federatedClientIssuer + "/jwks.json",
},
{Issuer: federatedClientIssuerDefaults}, {Issuer: federatedClientIssuerDefaults},
}, },
}, },
@@ -467,43 +461,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Generate a token // Generate a token
input := dto.OidcCreateTokensDto{ 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), ClientAssertion: string(signedToken),
ClientAssertionType: ClientAssertionTypeJWTBearer, ClientAssertionType: ClientAssertionTypeJWTBearer,
} }
@@ -526,7 +483,6 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
t.Run("Fails with invalid assertion", func(t *testing.T) { t.Run("Fails with invalid assertion", func(t *testing.T) {
input := dto.OidcCreateTokensDto{ input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientAssertion: "invalid.jwt.token", ClientAssertion: "invalid.jwt.token",
ClientAssertionType: ClientAssertionTypeJWTBearer, ClientAssertionType: ClientAssertionTypeJWTBearer,
} }

View File

@@ -79,7 +79,7 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
tx.Rollback() tx.Rollback()
}() }()
user, err := s.userService.getUserInternal(ctx, userID, tx) user, err := s.userService.GetUser(ctx, userID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -131,32 +131,8 @@ func (s *OneTimeAccessService) requestOneTimeAccessEmailInternal(ctx context.Con
} }
func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) { func (s *OneTimeAccessService) CreateOneTimeAccessToken(ctx context.Context, userID string, ttl time.Duration) (token string, err error) {
tx := s.db.Begin() token, _, err = s.createOneTimeAccessTokenInternal(ctx, userID, ttl, false, s.db)
defer func() { return token, err
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) { func (s *OneTimeAccessService) createOneTimeAccessTokenInternal(ctx context.Context, userID string, ttl time.Duration, withDeviceToken bool, tx *gorm.DB) (token string, deviceToken *string, err error) {

View File

@@ -1,25 +0,0 @@
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
}

View File

@@ -11,7 +11,6 @@ import (
"net/http" "net/http"
"net/url" "net/url"
"path" "path"
"slices"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -34,6 +33,11 @@ const scimErrorBodyLimit = 4096
type scimSyncAction int 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 ( const (
scimActionNone scimSyncAction = iota scimActionNone scimSyncAction = iota
scimActionCreated scimActionCreated
@@ -144,7 +148,7 @@ func (s *ScimService) ScheduleSync() {
err := s.scheduler.RegisterJob( err := s.scheduler.RegisterJob(
context.Background(), jobName, context.Background(), jobName,
gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, RegisterJobOpts{}) gocron.OneTimeJob(gocron.OneTimeJobStartDateTime(start)), s.SyncAll, false)
if err != nil { if err != nil {
slog.Error("Failed to schedule SCIM sync", slog.Any("error", err)) slog.Error("Failed to schedule SCIM sync", slog.Any("error", err))
@@ -163,8 +167,7 @@ func (s *ScimService) SyncAll(ctx context.Context) error {
errs = append(errs, ctx.Err()) errs = append(errs, ctx.Err())
break break
} }
err = s.SyncServiceProvider(ctx, provider.ID) if err := s.SyncServiceProvider(ctx, provider.ID); err != nil {
if err != nil {
errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err)) errs = append(errs, fmt.Errorf("failed to sync SCIM provider %s: %w", provider.ID, err))
} }
} }
@@ -206,20 +209,26 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
} }
var errs []error var errs []error
var userStats scimSyncStats
var groupStats scimSyncStats
// Sync users first, so that groups can reference them // Sync users first, so that groups can reference them
userStats, err := s.syncUsers(ctx, provider, users, &userResources) if stats, err := s.syncUsers(ctx, provider, users, &userResources); err != nil {
if err != nil {
errs = append(errs, err) errs = append(errs, err)
userStats = stats
} else {
userStats = stats
} }
groupStats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources) stats, err := s.syncGroups(ctx, provider, groups, groupResources.Resources, userResources.Resources)
if err != nil { if err != nil {
errs = append(errs, err) errs = append(errs, err)
groupStats = stats
} else {
groupStats = stats
} }
if len(errs) > 0 { if len(errs) > 0 {
err = errors.Join(errs...)
slog.WarnContext(ctx, "SCIM sync completed with errors", slog.WarnContext(ctx, "SCIM sync completed with errors",
slog.String("provider_id", provider.ID), slog.String("provider_id", provider.ID),
slog.Int("error_count", len(errs)), slog.Int("error_count", len(errs)),
@@ -230,14 +239,12 @@ func (s *ScimService) SyncServiceProvider(ctx context.Context, serviceProviderID
slog.Int("groups_updated", groupStats.Updated), slog.Int("groups_updated", groupStats.Updated),
slog.Int("groups_deleted", groupStats.Deleted), slog.Int("groups_deleted", groupStats.Deleted),
slog.Duration("duration", time.Since(start)), slog.Duration("duration", time.Since(start)),
slog.Any("error", err),
) )
return err return errors.Join(errs...)
} }
provider.LastSyncedAt = new(datatype.DateTime(time.Now())) provider.LastSyncedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = s.db.WithContext(ctx).Save(&provider).Error if err := s.db.WithContext(ctx).Save(&provider).Error; err != nil {
if err != nil {
return err return err
} }
@@ -265,7 +272,7 @@ func (s *ScimService) syncUsers(
// Update or create users // Update or create users
for _, u := range users { for _, u := range users {
existing := getResourceByExternalID(u.ID, resourceList.Resources) existing := getResourceByExternalID[dto.ScimUser](u.ID, resourceList.Resources)
action, created, err := s.syncUser(ctx, provider, u, existing) action, created, err := s.syncUser(ctx, provider, u, existing)
if created != nil && existing == nil { if created != nil && existing == nil {
@@ -426,7 +433,7 @@ func (s *ScimService) syncGroup(
// Prepare group members // Prepare group members
members := make([]dto.ScimGroupMember, len(group.Users)) members := make([]dto.ScimGroupMember, len(group.Users))
for i, user := range group.Users { for i, user := range group.Users {
userResource := getResourceByExternalID(user.ID, userResources) userResource := getResourceByExternalID[dto.ScimUser](user.ID, userResources)
if userResource == nil { if userResource == nil {
// Groups depend on user IDs already being provisioned // 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) return scimActionNone, fmt.Errorf("cannot sync group %s: user %s is not provisioned in SCIM provider", group.ID, user.ID)
@@ -781,8 +788,10 @@ func ensureScimStatus(
resp *http.Response, resp *http.Response,
provider model.ScimServiceProvider, provider model.ScimServiceProvider,
allowedStatuses ...int) error { allowedStatuses ...int) error {
if slices.Contains(allowedStatuses, resp.StatusCode) { for _, status := range allowedStatuses {
return nil if resp.StatusCode == status {
return nil
}
} }
body := readScimErrorBody(resp.Body) body := readScimErrorBody(resp.Body)

View File

@@ -96,10 +96,7 @@ func (s *UserGroupService) Delete(ctx context.Context, id string) error {
return err return err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return nil return nil
} }
@@ -129,10 +126,7 @@ func (s *UserGroupService) createInternal(ctx context.Context, input dto.UserGro
return model.UserGroup{}, err return model.UserGroup{}, err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return group, nil return group, nil
} }
@@ -168,7 +162,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
group.Name = input.Name group.Name = input.Name
group.FriendlyName = input.FriendlyName group.FriendlyName = input.FriendlyName
group.UpdatedAt = new(datatype.DateTime(time.Now())) group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
@@ -181,10 +175,7 @@ func (s *UserGroupService) updateInternal(ctx context.Context, id string, input
return model.UserGroup{}, err return model.UserGroup{}, err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return group, nil return group, nil
} }
@@ -237,7 +228,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
} }
// Save the updated group // Save the updated group
group.UpdatedAt = new(datatype.DateTime(time.Now())) group.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
@@ -247,10 +238,7 @@ func (s *UserGroupService) updateUsersInternal(ctx context.Context, id string, u
return model.UserGroup{}, err return model.UserGroup{}, err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return group, nil return group, nil
} }
@@ -327,9 +315,6 @@ func (s *UserGroupService) UpdateAllowedOidcClient(ctx context.Context, id strin
return model.UserGroup{}, err return model.UserGroup{}, err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return group, nil return group, nil
} }

View File

@@ -225,10 +225,7 @@ func (s *UserService) deleteUserInternal(ctx context.Context, tx *gorm.DB, userI
return fmt.Errorf("failed to delete user: %w", err) return fmt.Errorf("failed to delete user: %w", err)
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return nil return nil
} }
@@ -313,10 +310,7 @@ func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCrea
} }
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return user, nil return user, nil
} }
@@ -441,7 +435,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
} }
} }
user.UpdatedAt = new(datatype.DateTime(time.Now())) user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
@@ -462,10 +456,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
return user, err return user, err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return user, nil return user, nil
} }
@@ -510,9 +501,9 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
} }
// Update the UpdatedAt field for all affected groups // Update the UpdatedAt field for all affected groups
now := datatype.DateTime(time.Now()) now := time.Now()
for _, group := range groups { for _, group := range groups {
group.UpdatedAt = &now group.UpdatedAt = utils.Ptr(datatype.DateTime(now))
err = tx.WithContext(ctx).Save(&group).Error err = tx.WithContext(ctx).Save(&group).Error
if err != nil { if err != nil {
return model.User{}, err return model.User{}, err
@@ -524,10 +515,7 @@ func (s *UserService) UpdateUserGroups(ctx context.Context, id string, userGroup
return model.User{}, err return model.User{}, err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return user, nil return user, nil
} }
@@ -588,10 +576,7 @@ func (s *UserService) disableUserInternal(ctx context.Context, tx *gorm.DB, user
return err return err
} }
if s.scimService != nil { s.scimService.ScheduleSync()
s.scimService.ScheduleSync()
}
return nil return nil
} }
@@ -651,7 +636,7 @@ func (s *UserService) VerifyEmail(ctx context.Context, userID string, token stri
} }
user.EmailVerified = true user.EmailVerified = true
user.UpdatedAt = new(datatype.DateTime(time.Now())) user.UpdatedAt = utils.Ptr(datatype.DateTime(time.Now()))
err = tx.WithContext(ctx).Save(&user).Error err = tx.WithContext(ctx).Save(&user).Error
if err != nil { if err != nil {
return err return err

View File

@@ -125,9 +125,7 @@ func (s *UserSignUpService) SignUpInitialAdmin(ctx context.Context, signUpData d
}() }()
var userCount int64 var userCount int64
if err := tx.WithContext(ctx).Model(&model.User{}). if err := tx.WithContext(ctx).Model(&model.User{}).Count(&userCount).Error; err != nil {
Where("id != ?", staticApiKeyUserID).
Count(&userCount).Error; err != nil {
return model.User{}, "", err return model.User{}, "", err
} }
if userCount != 0 { if userCount != 0 {

View File

@@ -10,7 +10,6 @@ import (
"strings" "strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/common"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )
@@ -32,10 +31,6 @@ func NewVersionService(httpClient *http.Client) *VersionService {
} }
func (s *VersionService) GetLatestVersion(ctx context.Context) (string, error) { 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) { version, err := s.cache.GetOrFetch(ctx, func(ctx context.Context) (string, error) {
reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second) reqCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() defer cancel()

View File

@@ -1,31 +1,14 @@
package utils package utils
import ( import (
"log/slog"
"net" "net"
"net/url" "net/url"
"path" "path"
"strconv" "regexp"
"strings" "strings"
"github.com/dunglas/go-urlpattern"
) )
// ValidateCallbackURLPattern checks if the given callback URL pattern // GetCallbackURLFromList returns the first callback URL that matches the input callback URL
// 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) { func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL string, err error) {
// Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3: // Special case for Loopback Interface Redirection. Quoting from RFC 8252 section 7.3:
// https://datatracker.ietf.org/doc/html/rfc8252#section-7.3 // https://datatracker.ietf.org/doc/html/rfc8252#section-7.3
@@ -34,7 +17,17 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
// time of the request for loopback IP redirect URIs, to accommodate // time of the request for loopback IP redirect URIs, to accommodate
// clients that obtain an available ephemeral port from the operating // clients that obtain an available ephemeral port from the operating
// system at the time of the request. // system at the time of the request.
loopbackCallbackURLWithoutPort := loopbackURLWithWildcardPort(inputCallbackURL) 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()
}
}
for _, pattern := range urls { for _, pattern := range urls {
// Try the original callback first // Try the original callback first
@@ -61,28 +54,6 @@ func GetCallbackURLFromList(urls []string, inputCallbackURL string) (callbackURL
return "", nil 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. // matchCallbackURL checks if the input callback URL matches the given pattern.
// It supports wildcard matching for paths and query parameters. // It supports wildcard matching for paths and query parameters.
// //
@@ -93,176 +64,143 @@ func matchCallbackURL(pattern string, inputCallbackURL string) (matches bool, er
return true, nil return true, nil
} }
// Strip fragment part. // Strip fragment part
// The endpoint URI MUST NOT include a fragment component. // The endpoint URI MUST NOT include a fragment component.
// https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2 // https://datatracker.ietf.org/doc/html/rfc6749#section-3.1.2
pattern, _, _ = strings.Cut(pattern, "#") pattern, _, _ = strings.Cut(pattern, "#")
inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#") inputCallbackURL, _, _ = strings.Cut(inputCallbackURL, "#")
// Store and strip query part // Store and strip query part
pattern, patternQuery, err := extractQueryParams(pattern) var patternQuery url.Values
if err != nil { if i := strings.Index(pattern, "?"); i >= 0 {
return false, err patternQuery, err = url.ParseQuery(pattern[i+1:])
}
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 { if err != nil {
return "", nil, err return false, err
} }
rawUrl = rawUrl[:i] 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]
} }
return rawUrl, query, nil // Split both pattern and input parts
} patternParts, patternPath := splitParts(pattern)
inputParts, inputPath := splitParts(inputCallbackURL)
func validateQueryParams(patternQuery, inputQuery url.Values) bool { // Verify everything except the path and query parameters
if len(patternParts) != len(inputParts) {
return false, nil
}
for i, patternPart := range patternParts {
matched, err := path.Match(patternPart, inputParts[i])
if err != nil || !matched {
return false, err
}
}
// Verify path with wildcard support
matched, err := matchPath(patternPath, inputPath)
if err != nil || !matched {
return false, err
}
// Verify query parameters
if len(patternQuery) != len(inputQuery) { if len(patternQuery) != len(inputQuery) {
return false return false, nil
} }
for patternKey, patternValues := range patternQuery { for patternKey, patternValues := range patternQuery {
inputValues, exists := inputQuery[patternKey] inputValues, exists := inputQuery[patternKey]
if !exists { if !exists {
return false return false, nil
} }
if len(patternValues) != len(inputValues) { if len(patternValues) != len(inputValues) {
return false return false, nil
} }
for i := range patternValues { for i := range patternValues {
matched, err := path.Match(patternValues[i], inputValues[i]) matched, err := path.Match(patternValues[i], inputValues[i])
if err != nil || !matched { if err != nil || !matched {
return false return false, err
} }
} }
} }
return true return true, nil
}
// matchPath matches the input path against the pattern with wildcard support
// Supported wildcards:
//
// '*' matches any sequence of characters except '/'
// '**' matches any sequence of characters including '/'
func matchPath(pattern string, input string) (matches bool, err error) {
var regexPattern strings.Builder
regexPattern.WriteString("^")
runes := []rune(pattern)
n := len(runes)
for i := 0; i < n; {
switch runes[i] {
case '*':
// Check if it's a ** (globstar)
if i+1 < n && runes[i+1] == '*' {
// globstar = .* (match slashes too)
regexPattern.WriteString(".*")
i += 2
} else {
// single * = [^/]* (no slash)
regexPattern.WriteString(`[^/]*`)
i++
}
default:
regexPattern.WriteString(regexp.QuoteMeta(string(runes[i])))
i++
}
}
regexPattern.WriteString("$")
matched, err := regexp.MatchString(regexPattern.String(), input)
return matched, err
}
// splitParts splits the URL into parts by special characters and returns the path separately
func splitParts(s string) (parts []string, path string) {
split := func(r rune) bool {
return r == ':' || r == '/' || r == '[' || r == ']' || r == '@' || r == '.'
}
pathStart := -1
// Look for scheme:// first
if i := strings.Index(s, "://"); i >= 0 {
// Look for the next slash after scheme://
rest := s[i+3:]
if j := strings.IndexRune(rest, '/'); j >= 0 {
pathStart = i + 3 + j
}
} else {
// Otherwise, first slash is path start
pathStart = strings.IndexRune(s, '/')
}
if pathStart >= 0 {
path = s[pathStart:]
base := s[:pathStart]
parts = strings.FieldsFunc(base, split)
} else {
parts = strings.FieldsFunc(s, split)
path = ""
}
return parts, path
} }

View File

@@ -7,142 +7,6 @@ import (
"github.com/stretchr/testify/require" "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) { func TestMatchCallbackURL(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -163,18 +27,6 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com/callback", "https://example.com/callback",
false, 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 // Scheme
{ {
@@ -259,30 +111,6 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com:8080/callback", "https://example.com:8080/callback",
true, 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 // Path
{ {
@@ -303,18 +131,6 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com/callback", "https://example.com/callback",
true, 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", "partial wildcard in path prefix",
"https://example.com/test*", "https://example.com/test*",
@@ -371,6 +187,12 @@ func TestMatchCallbackURL(t *testing.T) {
"https://example.com/callback", "https://example.com/callback",
false, false,
}, },
{
"unexpected credentials",
"https://example.com/callback",
"https://user:pass@example.com/callback",
false,
},
{ {
"wildcard password", "wildcard password",
"https://user:*@example.com/callback", "https://user:*@example.com/callback",
@@ -525,7 +347,7 @@ func TestMatchCallbackURL(t *testing.T) {
"backslash instead of forward slash", "backslash instead of forward slash",
"https://example.com/callback", "https://example.com/callback",
"https://example.com\\callback", "https://example.com\\callback",
true, false,
}, },
{ {
"double slash in hostname (protocol smuggling)", "double slash in hostname (protocol smuggling)",
@@ -548,11 +370,10 @@ func TestMatchCallbackURL(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { matches, err := matchCallbackURL(tt.pattern, tt.input)
matches, err := matchCallbackURL(tt.pattern, tt.input) require.NoError(t, err, tt.name)
require.NoError(t, err) assert.Equal(t, tt.shouldMatch, matches, tt.name)
assert.Equal(t, tt.shouldMatch, matches)
})
} }
} }
@@ -586,24 +407,17 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
expectMatch: true, expectMatch: true,
}, },
{ {
name: "IPv6 loopback with dynamic port - exact match", name: "IPv6 loopback with dynamic port",
urls: []string{"http://[::1]/callback"}, urls: []string{"http://[::1]/callback"},
inputCallbackURL: "http://[::1]:8080/callback", inputCallbackURL: "http://[::1]:8080/callback",
expectedURL: "http://[::1]:8080/callback", expectedURL: "http://[::1]:8080/callback",
expectMatch: true, expectMatch: true,
}, },
{ {
name: "IPv6 loopback with same port - exact match", name: "IPv6 loopback without brackets in input",
urls: []string{"http://[::1]:8080/callback"}, urls: []string{"http://[::1]/callback"},
inputCallbackURL: "http://[::1]:8080/callback", inputCallbackURL: "http://::1:8080/callback",
expectedURL: "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, expectMatch: true,
}, },
{ {
@@ -627,20 +441,6 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
expectedURL: "http://127.0.0.1:3000/auth/callback", expectedURL: "http://127.0.0.1:3000/auth/callback",
expectMatch: true, 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", name: "loopback with path mismatch",
urls: []string{"http://127.0.0.1/callback"}, urls: []string{"http://127.0.0.1/callback"},
@@ -662,13 +462,6 @@ func TestGetCallbackURLFromList_LoopbackSpecialHandling(t *testing.T) {
expectedURL: "http://127.0.0.1:8080/callback", expectedURL: "http://127.0.0.1:8080/callback",
expectMatch: true, 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 { for _, tt := range tests {
@@ -684,76 +477,6 @@ 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) { func TestGetCallbackURLFromList_MultiplePatterns(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -823,3 +546,246 @@ 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")
})
}
}

View File

@@ -35,7 +35,7 @@ func MigrateDatabase(sqlDb *sql.DB) error {
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion) return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
} }
slog.Info("Fetching migrations from GitHub to handle possible downgrades") slog.Info("Fetching migrations from GitHub to handle possible downgrades")
return migrateDatabaseFromGitHub(sqlDb, requiredVersion, currentVersion) return migrateDatabaseFromGitHub(sqlDb, requiredVersion)
} }
err = m.Migrate(requiredVersion) err = m.Migrate(requiredVersion)
@@ -92,7 +92,7 @@ func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver dat
} }
// migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades. // migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades.
func migrateDatabaseFromGitHub(sqlDb *sql.DB, requiredVersion uint, currentVersion uint) error { func migrateDatabaseFromGitHub(sqlDb *sql.DB, version uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider) srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider) driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider)
@@ -105,18 +105,9 @@ func migrateDatabaseFromGitHub(sqlDb *sql.DB, requiredVersion uint, currentVersi
return fmt.Errorf("failed to create GitHub migration instance: %w", err) return fmt.Errorf("failed to create GitHub migration instance: %w", err)
} }
// Reset the dirty state before forcing the version if err := m.Force(int(version)); err != nil && !errors.Is(err, migrate.ErrNoChange) { //nolint:gosec
if err := m.Force(int(currentVersion)); err != nil { //nolint:gosec
return fmt.Errorf("failed to force database version: %w", err)
}
if err := m.Migrate(requiredVersion); err != nil {
if errors.Is(err, migrate.ErrNoChange) {
return nil
}
return fmt.Errorf("failed to apply GitHub migrations: %w", err) return fmt.Errorf("failed to apply GitHub migrations: %w", err)
} }
return nil return nil
} }

View File

@@ -2,7 +2,6 @@ package utils
import ( import (
"net/http" "net/http"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -22,27 +21,6 @@ func BearerAuth(r *http.Request) (string, bool) {
return "", false 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. // SetCacheControlHeader sets the Cache-Control header for the response.
func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) { func SetCacheControlHeader(ctx *gin.Context, maxAge, staleWhileRevalidate time.Duration) {
_, ok := ctx.GetQuery("skipCache") _, ok := ctx.GetQuery("skipCache")

View File

@@ -63,62 +63,3 @@ 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)
}
})
}
}

View File

@@ -87,9 +87,9 @@ func listContainsIP(ipNets []*net.IPNet, ip net.IP) bool {
func loadLocalIPv6Ranges() { func loadLocalIPv6Ranges() {
localIPv6Ranges = nil localIPv6Ranges = nil
ranges := strings.SplitSeq(common.EnvConfig.LocalIPv6Ranges, ",") ranges := strings.Split(common.EnvConfig.LocalIPv6Ranges, ",")
for rangeStr := range ranges { for _, rangeStr := range ranges {
rangeStr = strings.TrimSpace(rangeStr) rangeStr = strings.TrimSpace(rangeStr)
if rangeStr == "" { if rangeStr == "" {
continue continue

View File

@@ -42,7 +42,7 @@ func (d *JSONDuration) UnmarshalJSON(b []byte) error {
} }
} }
func UnmarshalJSONFromDatabase(data any, value any) error { func UnmarshalJSONFromDatabase(data interface{}, value any) error {
switch v := value.(type) { switch v := value.(type) {
case []byte: case []byte:
return json.Unmarshal(v, data) return json.Unmarshal(v, data)

View File

@@ -43,7 +43,7 @@ func ParseListRequestOptions(ctx *gin.Context) (listRequestOptions ListRequestOp
return listRequestOptions return listRequestOptions
} }
func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any) (PaginationResponse, error) { func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result interface{}) (PaginationResponse, error) {
meta := extractModelMetadata(result) meta := extractModelMetadata(result)
query = applyFilters(params.Filters, query, meta) query = applyFilters(params.Filters, query, meta)
@@ -52,7 +52,7 @@ func PaginateFilterAndSort(params ListRequestOptions, query *gorm.DB, result any
return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result) return Paginate(params.Pagination.Page, params.Pagination.Limit, query, result)
} }
func Paginate(page int, pageSize int, query *gorm.DB, result any) (PaginationResponse, error) { func Paginate(page int, pageSize int, query *gorm.DB, result interface{}) (PaginationResponse, error) {
if page < 1 { if page < 1 {
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]" // Keys can be "filters[field]" or "filters[field][0]"
raw := strings.TrimPrefix(key, "filters[") raw := strings.TrimPrefix(key, "filters[")
// Take everything up to the first closing bracket // Take everything up to the first closing bracket
if before, _, ok := strings.Cut(raw, "]"); ok { if idx := strings.IndexByte(raw, ']'); idx != -1 {
field := before field := raw[:idx]
for _, v := range values { for _, v := range values {
result[field] = append(result[field], ConvertStringToType(v)) 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 // extractModelMetadata extracts FieldMeta from the model struct using reflection
func extractModelMetadata(model any) map[string]FieldMeta { func extractModelMetadata(model interface{}) map[string]FieldMeta {
meta := make(map[string]FieldMeta) meta := make(map[string]FieldMeta)
// Unwrap pointers and slices to get the element struct type // Unwrap pointers and slices to get the element struct type
t := reflect.TypeOf(model) t := reflect.TypeOf(model)
for t.Kind() == reflect.Pointer || t.Kind() == reflect.Slice { for t.Kind() == reflect.Ptr || t.Kind() == reflect.Slice {
t = t.Elem() t = t.Elem()
if t == nil { if t == nil {
return meta return meta
@@ -180,7 +180,8 @@ func extractModelMetadata(model any) map[string]FieldMeta {
// recursive parser that merges fields from embedded structs // recursive parser that merges fields from embedded structs
var parseStruct func(reflect.Type) var parseStruct func(reflect.Type)
parseStruct = func(st reflect.Type) { parseStruct = func(st reflect.Type) {
for field := range st.Fields() { for i := 0; i < st.NumField(); i++ {
field := st.Field(i)
ft := field.Type ft := field.Type
// If the field is an embedded/anonymous struct, recurse into it // If the field is an embedded/anonymous struct, recurse into it

View File

@@ -1,35 +0,0 @@
//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
}
}

View File

@@ -1,8 +0,0 @@
//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
}

View File

@@ -1,5 +1,10 @@
package utils 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, // PtrOrNil returns a pointer to v if v is not the zero value of its type,
// otherwise it returns nil. // otherwise it returns nil.
func PtrOrNil[T comparable](v T) *T { func PtrOrNil[T comparable](v T) *T {

View File

@@ -0,0 +1,85 @@
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
}

View File

@@ -70,6 +70,11 @@ func GetHostnameFromURL(rawURL string) string {
return parsedURL.Hostname() return parsedURL.Hostname()
} }
// StringPointer creates a string pointer from a string value
func StringPointer(s string) *string {
return &s
}
func CapitalizeFirstLetter(str string) string { func CapitalizeFirstLetter(str string) string {
if str == "" { if str == "" {
return "" return ""

File diff suppressed because one or more lines are too long

View File

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

View File

@@ -6,6 +6,6 @@ API KEY EXPIRING SOON
Warning Warning
Hello {{.Data.Name}}, 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}} Please generate a new API key if you need continued access.{{end}}

View File

@@ -1,6 +0,0 @@
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);

View File

@@ -1,12 +0,0 @@
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;

View File

@@ -4,7 +4,7 @@
ARG BUILD_TAGS="" ARG BUILD_TAGS=""
# Stage 1: Build Frontend # Stage 1: Build Frontend
FROM node:24-alpine AS frontend-builder FROM node:22-alpine AS frontend-builder
RUN corepack enable RUN corepack enable
WORKDIR /build WORKDIR /build
@@ -18,7 +18,7 @@ COPY ./frontend ./frontend/
RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build RUN BUILD_OUTPUT_PATH=dist pnpm --filter pocket-id-frontend run build
# Stage 2: Build Backend # Stage 2: Build Backend
FROM golang:1.26-alpine AS backend-builder FROM golang:1.25-alpine AS backend-builder
ARG BUILD_TAGS ARG BUILD_TAGS
WORKDIR /build WORKDIR /build
COPY ./backend/go.mod ./backend/go.sum ./ COPY ./backend/go.mod ./backend/go.sum ./

View File

@@ -40,7 +40,7 @@ ApiKeyExpiringEmail.TemplateProps = {
...sharedTemplateProps, ...sharedTemplateProps,
data: { data: {
name: "{{.Data.Name}}", name: "{{.Data.Name}}",
apiKeyName: "{{.Data.ApiKeyName}}", apiKeyName: "{{.Data.APIKeyName}}",
expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}', expiresAt: '{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}',
}, },
}; };

View File

@@ -5,5 +5,4 @@ yarn.lock
# Compiled files # Compiled files
.svelte-kit/ .svelte-kit/
build/ build/
src/lib/paraglide/messages

View File

@@ -46,11 +46,7 @@
"authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče", "authenticator_does_not_support_resident_keys": "Autentikátor nepodporuje rezidentní klíče",
"passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován", "passkey_was_previously_registered": "Tento přístupový klíč byl již dříve zaregistrován",
"authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů", "authenticator_does_not_support_any_of_the_requested_algorithms": "Autentikátor nepodporuje žádný z požadovaných algoritmů",
"webauthn_error_invalid_rp_id": "Nakonfigurované ID spoléhající strany je neplatné.", "authenticator_timed_out": "Vypršel časový limit autentifikátoru",
"webauthn_error_invalid_domain": "Nakonfigurovaná doména je neplatná.",
"contact_administrator_to_fix": "Kontaktujte svého správce, aby tento problém vyřešil.",
"webauthn_operation_not_allowed_or_timed_out": "Operace nebyla povolena nebo vypršela časová lhůta.",
"webauthn_not_supported_by_browser": "Tento prohlížeč nepodporuje přístupové klíče. Použijte prosím alternativní způsob přihlášení.",
"critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.", "critical_error_occurred_contact_administrator": "Došlo k kritické chybě. Obraťte se na správce.",
"sign_in_to": "Přihlásit se k {name}", "sign_in_to": "Přihlásit se k {name}",
"client_not_found": "Klient nebyl nalezen", "client_not_found": "Klient nebyl nalezen",
@@ -196,6 +192,8 @@
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Doba trvání relace v minutách, než se uživatel musí znovu přihlásit.", "the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "Doba trvání relace v minutách, než se uživatel musí znovu přihlásit.",
"enable_self_account_editing": "Povolit úpravy vlastního účtu", "enable_self_account_editing": "Povolit úpravy vlastního účtu",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Zda by uživatelé měli mít možnost upravit vlastní údaje o účtu.", "whether_the_users_should_be_able_to_edit_their_own_account_details": "Zda by uživatelé měli mít možnost upravit vlastní údaje o účtu.",
"emails_verified": "E-mail ověřen",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Zda má být e-mail uživatele označen jako ověřený pro OIDC klienty.",
"ldap_configuration_updated_successfully": "Nastavení LDAP bylo úspěšně aktualizováno", "ldap_configuration_updated_successfully": "Nastavení LDAP bylo úspěšně aktualizováno",
"ldap_disabled_successfully": "LDAP úspěšně zakázán", "ldap_disabled_successfully": "LDAP úspěšně zakázán",
"ldap_sync_finished": "LDAP synchronizace dokončena", "ldap_sync_finished": "LDAP synchronizace dokončena",
@@ -365,7 +363,7 @@
"enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.", "enter_code_displayed_in_previous_step": "Zadejte kód, který byl zobrazen v předchozím kroku.",
"authorize": "Autorizovat", "authorize": "Autorizovat",
"federated_client_credentials": "Údaje o klientovi ve federaci", "federated_client_credentials": "Údaje o klientovi ve federaci",
"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í.", "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.",
"add_federated_client_credential": "Přidat údaje federovaného klienta", "add_federated_client_credential": "Přidat údaje federovaného klienta",
"add_another_federated_client_credential": "Přidat dalšího federovaného klienta", "add_another_federated_client_credential": "Přidat dalšího federovaného klienta",
"oidc_allowed_group_count": "Počet povolených skupin", "oidc_allowed_group_count": "Počet povolených skupin",
@@ -501,25 +499,5 @@
"save_and_sync": "Uložit a synchronizovat", "save_and_sync": "Uložit a synchronizovat",
"scim_save_changes_description": "Před spuštěním synchronizace SCIM je nutné uložit změny. Chcete uložit nyní?", "scim_save_changes_description": "Před spuštěním synchronizace SCIM je nutné uložit změny. Chcete uložit nyní?",
"scopes": "Rozsah", "scopes": "Rozsah",
"issuer_url": "URL vydavatele", "issuer_url": "URL vydavatele"
"smtp_field_required_when_other_provided": "Vyžadováno, pokud je zadáno jakékoli nastavení SMTP",
"smtp_field_required_when_email_enabled": "Vyžadováno, pokud jsou povolena e-mailová oznámení",
"renew": "Obnovit",
"renew_api_key": "Obnovit klíč API",
"renew_api_key_description": "Obnovením klíče API se vygeneruje nový klíč. Nezapomeňte aktualizovat všechny integrace, které tento klíč používají.",
"api_key_renewed": "API klíč obnoven",
"app_config_home_page": "Domovská stránka",
"app_config_home_page_description": "Stránka, na kterou jsou uživatelé přesměrováni po přihlášení.",
"email_verification_warning": "Ověřte svou e-mailovou adresu",
"email_verification_warning_description": "Vaše e-mailová adresa ještě nebyla ověřena. Ověřte ji prosím co nejdříve.",
"email_verification": "Ověření e-mailu",
"email_verification_description": "Po odeslání registrace nebo změně e-mailové adresy zašlete uživatelům ověřovací e-mail.",
"email_verification_success_title": "E-mail byl úspěšně ověřen",
"email_verification_success_description": "Vaše e-mailová adresa byla úspěšně ověřena.",
"email_verification_error_title": "Ověření e-mailu se nezdařilo",
"mark_as_unverified": "Označit jako neověřené",
"mark_as_verified": "Označit jako ověřené",
"email_verification_sent": "Ověřovací e-mail byl úspěšně odeslán.",
"emails_verified_by_default": "E-maily ověřené ve výchozím nastavení",
"emails_verified_by_default_description": "Pokud je tato funkce povolena, budou e-mailové adresy uživatelů při registraci nebo při změně e-mailové adresy automaticky označeny jako ověřené."
} }

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