mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-03-29 18:56:36 +00:00
Compare commits
7 Commits
callback-u
...
feat/email
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d40d30d87 | ||
|
|
d318b02ea0 | ||
|
|
dd8e4dec6c | ||
|
|
ca4e332964 | ||
|
|
20ee00df49 | ||
|
|
0a49c8b699 | ||
|
|
7d71191902 |
@@ -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": {
|
||||||
|
|||||||
4
.github/workflows/backend-linter.yml
vendored
4
.github/workflows/backend-linter.yml
vendored
@@ -32,9 +32,9 @@ jobs:
|
|||||||
go-version-file: backend/go.mod
|
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' }}
|
||||||
|
|||||||
2
.github/workflows/build-next.yml
vendored
2
.github/workflows/build-next.yml
vendored
@@ -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
|
||||||
|
|||||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -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
|
||||||
|
|||||||
106
.github/workflows/pr-quality.yml
vendored
106
.github/workflows/pr-quality.yml
vendored
@@ -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
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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"
|
||||||
|
|||||||
2
.github/workflows/svelte-check.yml
vendored
2
.github/workflows/svelte-check.yml
vendored
@@ -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
|
||||||
|
|||||||
128
CHANGELOG.md
128
CHANGELOG.md
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
143
backend/go.mod
143
backend/go.mod
@@ -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
|
||||||
)
|
)
|
||||||
|
|||||||
342
backend/go.sum
342
backend/go.sum
@@ -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=
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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++
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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, ",")
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=="),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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:]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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") {
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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 {
|
||||||
|
|||||||
85
backend/internal/utils/slogfanout.go
Normal file
85
backend/internal/utils/slogfanout.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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
@@ -1 +1 @@
|
|||||||
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.ApiKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
{{define "root"}}<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"><html dir="ltr" lang="en"><head><link rel="preload" as="image" href="{{.LogoURL}}"/><meta content="text/html; charset=UTF-8" http-equiv="Content-Type"/><meta name="x-apple-disable-message-reformatting"/></head><body style="background-color:#FBFBFB"><!--$--><!--html--><!--head--><!--body--><table border="0" width="100%" cellPadding="0" cellSpacing="0" role="presentation" align="center"><tbody><tr><td style="padding:50px;background-color:#FBFBFB;font-family:Arial, sans-serif"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="max-width:37.5em;width:500px;margin:0 auto"><tbody><tr style="width:100%"><td><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody><tr><td><table align="left" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:50px"><img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle" width="32"/></td><td data-id="__react-email-column"><p style="font-size:23px;line-height:24px;font-weight:bold;margin:0;padding:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.AppName}}</p></td></tr></tbody></table></td></tr></tbody></table><div style="background-color:white;padding:24px;border-radius:10px;box-shadow:0 1px 4px 0px rgba(0, 0, 0, 0.1)"><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column"><h1 style="font-size:20px;font-weight:bold;margin:0">API Key Expiring Soon</h1></td><td align="right" data-id="__react-email-column"><p style="font-size:12px;line-height:24px;background-color:#ffd966;color:#7f6000;padding:1px 12px;border-radius:50px;display:inline-block;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Warning</p></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Hello <!-- -->{{.Data.Name}}<!-- -->, <br/>This is a reminder that your API key <strong>{{.Data.APIKeyName}}</strong> <!-- -->will expire on <strong>{{.Data.ExpiresAt.Format "2006-01-02 15:04:05 MST"}}</strong>.</p><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">Please generate a new API key if you need continued access.</p></div></td></tr></tbody></table></td></tr></tbody></table><!--/$--></body></html>{{end}}
|
||||||
@@ -6,6 +6,6 @@ API KEY EXPIRING SOON
|
|||||||
Warning
|
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}}
|
||||||
@@ -1 +0,0 @@
|
|||||||
-- No-op
|
|
||||||
@@ -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);
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
-- No-op
|
|
||||||
@@ -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;
|
|
||||||
@@ -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 ./
|
||||||
|
|||||||
@@ -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"}}',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,5 +5,4 @@ yarn.lock
|
|||||||
|
|
||||||
# Compiled files
|
# Compiled files
|
||||||
.svelte-kit/
|
.svelte-kit/
|
||||||
build/
|
build/
|
||||||
src/lib/paraglide/messages
|
|
||||||
@@ -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
Reference in New Issue
Block a user