Compare commits

...

55 Commits

Author SHA1 Message Date
Elias Schneider
8973e93cb6 release: 1.11.1 2025-09-18 22:33:22 +02:00
Elias Schneider
8c9cac2655 chore(translations): update translations via Crowdin (#957) 2025-09-18 22:26:38 +02:00
Elias Schneider
ed8547ccc1 release: 1.11.0 2025-09-18 22:16:32 +02:00
Elias Schneider
e7e53a8b8c fix: my apps card shouldn't take full width if only one item exists 2025-09-18 21:55:43 +02:00
Elias Schneider
02249491f8 feat: allow uppercase usernames (#958) 2025-09-17 14:43:12 -05:00
Elias Schneider
cf0892922b chore: include version in changelog 2025-09-17 18:00:04 +02:00
Elias Schneider
99f31a7c26 fix: make environment variables case insensitive where necessary (#954)
fix #935
2025-09-17 08:21:54 -07:00
Kyle Mendell
68373604dd feat: add user display name field (#898)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-17 17:18:27 +02:00
Elias Schneider
2d6d5df0e7 feat: add support for LOG_LEVEL env variable (#942) 2025-09-14 08:26:21 -07:00
Alessandro (Ale) Segala
a897b31166 chore: minify background image (#933)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-09-14 08:24:28 -07:00
dependabot[bot]
fb92906c3a chore(deps): bump axios from 1.11.0 to 1.12.0 in the npm_and_yarn group across 1 directory (#943)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-13 12:20:18 -05:00
Alessandro (Ale) Segala
c018f29ad7 fix: key-rotate doesn't work with database storage (#940) 2025-09-12 20:04:45 -05:00
Elias Schneider
5367463239 feat: add PWA support (#938) 2025-09-12 10:17:35 -05:00
Elias Schneider
6c9147483c fix: add validation for callback URLs (#929) 2025-09-10 10:14:54 -07:00
Elias Schneider
d123d7f335 chore(translations): update translations via Crowdin (#931) 2025-09-10 07:57:58 -05:00
dependabot[bot]
da8ca08c36 chore(deps-dev): bump vite from 7.0.6 to 7.0.7 in the npm_and_yarn group across 1 directory (#932)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-09 17:32:14 -05:00
Alessandro (Ale) Segala
307caaa3ef feat: return new id_token when using refresh token (#925) 2025-09-09 11:31:50 +02:00
Elias Schneider
6c696b46c8 fix: list items on previous page get unselected if other items selected on next page 2025-09-09 10:02:59 +02:00
Alessandro (Ale) Segala
42155238b7 fix: ensure users imported from LDAP have fields validated (#923) 2025-09-09 09:31:49 +02:00
Elias Schneider
92edc26a30 chore(translations): update translations via Crowdin (#924) 2025-09-08 08:12:21 -05:00
github-actions[bot]
e36499c483 chore: update AAGUIDs (#926)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-09-08 00:14:56 -05:00
Elias Schneider
6215e1ac01 feat: add CSP header (#908)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
2025-09-07 11:45:06 -07:00
Elias Schneider
74b39e16f9 chore(translations): update translations via Crowdin (#915) 2025-09-07 20:31:30 +02:00
Elias Schneider
a1d8538c64 feat: add info box to app settings if UI config is disabled 2025-09-07 19:49:07 +02:00
Elias Schneider
1d7cbc2a4e fix: disable sign up options in UI if UI_CONFIG_DISABLED 2025-09-07 19:42:20 +02:00
Kyle Mendell
954fb4f0c8 chore(translations): add Swedish files 2025-09-05 19:57:54 -05:00
Savely Krasovsky
901333f7e4 feat: client_credentials flow support (#901) 2025-09-02 18:33:01 -05:00
Elias Schneider
0b381467ca chore(translations): update translations via Crowdin (#904) 2025-09-02 09:57:31 -05:00
github-actions[bot]
6188dc6fb7 chore: update AAGUIDs (#903)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-31 19:40:23 -05:00
Kyle Mendell
802754c24c refactor: use react email for email templates (#734)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-31 16:54:13 +00:00
Elias Schneider
6c843228eb chore(translations): update translations via Crowdin (#893) 2025-08-30 13:20:35 -05:00
Stephan H.
a3979f63e0 feat: add custom base url (#858)
Co-authored-by: Stephan Höhn <me@steph.ovh>
Co-authored-by: Kyle Mendell <ksm@ofkm.us>
2025-08-30 13:13:57 -05:00
Elias Schneider
52c560c30d chore(translations): update translations via Crowdin (#887)
Co-authored-by: Kyle Mendell <kmendell@ofkm.us>
2025-08-27 16:44:26 -05:00
Kyle Mendell
e88be7e61a fix: update localized name and description of ldap group name attribute (#892) 2025-08-27 15:52:50 -05:00
Kyle Mendell
a4e965434f release: 1.10.0 2025-08-27 15:24:57 -05:00
Kyle Mendell
096d214a88 feat: redesigned sidebar with administrative dropdown (#881)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-27 16:39:22 +00:00
Savely Krasovsky
afb7fc32e7 chore(translations): add missing translations (#884) 2025-08-27 18:13:35 +02:00
Elias Schneider
641bbc9351 fix: apps showed multiple times if user is in multiple groups 2025-08-27 17:53:21 +02:00
Kyle Mendell
136c6082f6 chore(deps): bump sveltekit to 2.36.3 and devalue to 5.3.2 (#889) 2025-08-26 18:59:35 -05:00
github-actions[bot]
b9a20d2923 chore: update AAGUIDs (#885)
Co-authored-by: stonith404 <58886915+stonith404@users.noreply.github.com>
2025-08-25 08:13:32 +02:00
Elias Schneider
74eb2ac0b9 release: 1.9.1 2025-08-24 23:17:31 +02:00
Elias Schneider
51222f5607 tests: add no tx wrap to unit tests 2025-08-24 23:16:49 +02:00
Elias Schneider
d6d1a4ced2 fix: sqlite migration drops allowed user groups 2025-08-24 23:07:50 +02:00
Elias Schneider
4b086cebcd release: 1.9.0 2025-08-24 20:54:03 +02:00
Alessandro (Ale) Segala
1f3550c9bd fix: ensure SQLite has a writable temporary directory (#876)
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:51 +02:00
dependabot[bot]
912008b048 chore(deps): bump golang.org/x/oauth2 from 0.26.0 to 0.27.0 in /backend in the go_modules group across 1 directory (#879)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-08-24 20:50:30 +02:00
Elias Schneider
5ad8b03831 chore(translations): update translations via Crowdin (#878) 2025-08-24 20:42:58 +02:00
Elias Schneider
c1e515a05f ci/cd: use matrix for e2e tests 2025-08-24 20:35:30 +02:00
Elias Schneider
654593b4b6 chore(migrations): use TEXT instead of VARCHAR for client ID 2025-08-24 20:22:06 +02:00
Elias Schneider
8999173aa0 ci/cd: fix playwright browsers not installed 2025-08-24 20:16:57 +02:00
Elias Schneider
10b087640f tests: fix postgres e2e tests (#877) 2025-08-24 19:15:26 +02:00
Elias Schneider
d0392d25ed fix: sort order incorrect for apps when using postgres 2025-08-24 19:08:33 +02:00
Elias Schneider
2ffc6ba42a fix: don't force uuid for client id in postgres 2025-08-24 18:29:41 +02:00
Elias Schneider
c114a2edaa feat: support automatic db migration rollbacks (#874) 2025-08-24 16:56:28 +02:00
Elias Schneider
63db4d5120 chore(migrations): add postgres down migration to 20250822000000 2025-08-24 15:30:18 +02:00
218 changed files with 8815 additions and 1363 deletions

View File

@@ -23,8 +23,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -45,19 +45,23 @@ jobs:
path: /tmp/docker-image.tar path: /tmp/docker-image.tar
retention-days: 1 retention-days: 1
test-sqlite: test:
if: github.event.pull_request.head.ref != 'i18n_crowdin' if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions: permissions:
contents: read contents: read
actions: write actions: write
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: build needs: build
strategy:
fail-fast: false
matrix:
db: [sqlite, postgres]
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
with: with:
node-version: 22 node-version: 22
@@ -70,100 +74,8 @@ jobs:
with: with:
path: ~/.cache/ms-playwright path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }} key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Download Docker image artifact
uses: actions/download-artifact@v4
with:
name: docker-image
path: /tmp
- name: Load Docker image
run: docker load -i /tmp/docker-image.tar
- name: Cache LLDAP Docker image
uses: actions/cache@v3
id: lldap-cache
with:
path: /tmp/lldap-image.tar
key: lldap-stable-${{ runner.os }}
- name: Pull and save LLDAP image
if: steps.lldap-cache.outputs.cache-hit != 'true'
run: |
docker pull nitnelave/lldap:stable
docker save nitnelave/lldap:stable > /tmp/lldap-image.tar
- name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar
- name: Install test dependencies
run: pnpm --filter pocket-id-tests install --frozen-lockfile
- name: Install Playwright Browsers
working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm dlx playwright install --with-deps chromium
- name: Run Docker Container with Sqlite DB and LDAP
working-directory: ./tests/setup
run: |
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Playwright tests
working-directory: tests
run: pnpm exec playwright test
- name: Upload Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: playwright-report-sqlite
path: tests/.report
include-hidden-files: true
retention-days: 15
- name: Upload Backend Test Report
uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with:
name: backend-sqlite
path: /tmp/backend.log
include-hidden-files: true
retention-days: 15
test-postgres:
if: github.event.pull_request.head.ref != 'i18n_crowdin'
permissions:
contents: read
actions: write
runs-on: ubuntu-latest
needs: build
steps:
- uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'pnpm'
cache-dependency-path: pnpm-lock.yaml
- name: Cache Playwright Browsers
uses: actions/cache@v3
id: playwright-cache
with:
path: ~/.cache/ms-playwright
key: ${{ runner.os }}-playwright-${{ hashFiles('pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-playwright-
- name: Cache PostgreSQL Docker image - name: Cache PostgreSQL Docker image
if: matrix.db == 'postgres'
uses: actions/cache@v3 uses: actions/cache@v3
id: postgres-cache id: postgres-cache
with: with:
@@ -171,15 +83,14 @@ jobs:
key: postgres-17-${{ runner.os }} key: postgres-17-${{ runner.os }}
- name: Pull and save PostgreSQL image - name: Pull and save PostgreSQL image
if: steps.postgres-cache.outputs.cache-hit != 'true' if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit != 'true'
run: | run: |
docker pull postgres:17 docker pull postgres:17
docker save postgres:17 > /tmp/postgres-image.tar docker save postgres:17 > /tmp/postgres-image.tar
- name: Load PostgreSQL image from cache - name: Load PostgreSQL image from cache
if: steps.postgres-cache.outputs.cache-hit == 'true' if: matrix.db == 'postgres' && steps.postgres-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/postgres-image.tar run: docker load < /tmp/postgres-image.tar
- name: Cache LLDAP Docker image - name: Cache LLDAP Docker image
uses: actions/cache@v3 uses: actions/cache@v3
id: lldap-cache id: lldap-cache
@@ -196,7 +107,6 @@ jobs:
- name: Load LLDAP image from cache - name: Load LLDAP image from cache
if: steps.lldap-cache.outputs.cache-hit == 'true' if: steps.lldap-cache.outputs.cache-hit == 'true'
run: docker load < /tmp/lldap-image.tar run: docker load < /tmp/lldap-image.tar
- name: Download Docker image artifact - name: Download Docker image artifact
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@@ -207,14 +117,21 @@ jobs:
run: docker load -i /tmp/docker-image.tar run: docker load -i /tmp/docker-image.tar
- name: Install test dependencies - name: Install test dependencies
run: pnpm --filter pocket-id-tests install run: pnpm --filter pocket-id-tests install --frozen-lockfile
- name: Install Playwright Browsers - name: Install Playwright Browsers
working-directory: ./tests working-directory: ./tests
if: steps.playwright-cache.outputs.cache-hit != 'true' if: steps.playwright-cache.outputs.cache-hit != 'true'
run: pnpm dlx playwright install --with-deps chromium run: pnpm exec playwright install --with-deps chromium
- name: Run Docker Container (sqlite) with LDAP
if: matrix.db == 'sqlite'
working-directory: ./tests/setup
run: |
docker compose up -d
docker compose logs -f pocket-id &> /tmp/backend.log &
- name: Run Docker Container with Postgres DB and LDAP - name: Run Docker Container (postgres) with LDAP
if: matrix.db == 'postgres'
working-directory: ./tests/setup working-directory: ./tests/setup
run: | run: |
docker compose -f docker-compose-postgres.yml up -d docker compose -f docker-compose-postgres.yml up -d
@@ -228,8 +145,8 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: playwright-report-postgres name: playwright-report-${{ matrix.db }}
path: frontend/tests/.report path: tests/.report
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15
@@ -237,7 +154,7 @@ jobs:
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
if: always() && github.event.pull_request.head.ref != 'i18n_crowdin' if: always() && github.event.pull_request.head.ref != 'i18n_crowdin'
with: with:
name: backend-postgres name: backend-${{ matrix.db }}
path: /tmp/backend.log path: /tmp/backend.log
include-hidden-files: true include-hidden-files: true
retention-days: 15 retention-days: 15

View File

@@ -18,8 +18,6 @@ jobs:
uses: actions/checkout@v3 uses: actions/checkout@v3
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4
with: with:
@@ -71,6 +69,7 @@ jobs:
run: pnpm --filter pocket-id-frontend install --frozen-lockfile run: pnpm --filter pocket-id-frontend install --frozen-lockfile
- name: Build frontend - name: Build frontend
run: pnpm --filter pocket-id-frontend build run: pnpm --filter pocket-id-frontend build
- name: Build binaries - name: Build binaries
run: sh scripts/development/build-binaries.sh run: sh scripts/development/build-binaries.sh
- name: Build and push container image - name: Build and push container image

View File

@@ -38,8 +38,6 @@ jobs:
- name: Setup pnpm - name: Setup pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4
with:
version: 10
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v4 uses: actions/setup-node@v4

View File

@@ -1 +1 @@
1.8.1 1.11.1

View File

@@ -1,3 +1,66 @@
## [1.11.1](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.1) (2025-09-18)
### Bug Fixes
- add missing translations ([8c9cac2](https://github.com/pocket-id/pocket-id/commit/8c9cac2655ddbe4872234a1b55fdd51d2f3ac31c))
## [1.11.0](https://github.com/pocket-id/pocket-id/compare/v1.10.0...v1.11.0) (2025-09-18)
### Features
* add CSP header ([#908](https://github.com/pocket-id/pocket-id/issues/908)) ([6215e1a](https://github.com/pocket-id/pocket-id/commit/6215e1ac01c03866f8b2e89ac084ddd6a3c3ac9e))
* add custom base url ([#858](https://github.com/pocket-id/pocket-id/issues/858)) ([a3979f6](https://github.com/pocket-id/pocket-id/commit/a3979f63e07d418ee9eb1cb1abc37aede5799fc8))
* add info box to app settings if UI config is disabled ([a1d8538](https://github.com/pocket-id/pocket-id/commit/a1d8538c64beb4d7e8559934985772fba27623ca))
* add PWA support ([#938](https://github.com/pocket-id/pocket-id/issues/938)) ([5367463](https://github.com/pocket-id/pocket-id/commit/5367463239b354640fd65390bc409e4a0ac13fd1))
* add support for `LOG_LEVEL` env variable ([#942](https://github.com/pocket-id/pocket-id/issues/942)) ([2d6d5df](https://github.com/pocket-id/pocket-id/commit/2d6d5df0e7f104a148fb4eeac89a2fbb7db8047a))
* add user display name field ([#898](https://github.com/pocket-id/pocket-id/issues/898)) ([6837360](https://github.com/pocket-id/pocket-id/commit/68373604dd30065947226922233bc1e19e778b01))
* allow uppercase usernames ([#958](https://github.com/pocket-id/pocket-id/issues/958)) ([0224949](https://github.com/pocket-id/pocket-id/commit/02249491f86c289adf596d9d9922dfa04779edee))
* client_credentials flow support ([#901](https://github.com/pocket-id/pocket-id/issues/901)) ([901333f](https://github.com/pocket-id/pocket-id/commit/901333f7e43b4e925ed6dfd890dee2caa1947934))
* return new id_token when using refresh token ([#925](https://github.com/pocket-id/pocket-id/issues/925)) ([307caaa](https://github.com/pocket-id/pocket-id/commit/307caaa3efbc966341b95ee4b5ff18c81ed98e54))
### Bug Fixes
* add validation for callback URLs ([#929](https://github.com/pocket-id/pocket-id/issues/929)) ([6c91474](https://github.com/pocket-id/pocket-id/commit/6c9147483c0a370e2b5011d13898279d2acc445d))
* disable sign up options in UI if `UI_CONFIG_DISABLED` ([1d7cbc2](https://github.com/pocket-id/pocket-id/commit/1d7cbc2a4ecf352d46087f30b477f6bbaa23adf5))
* ensure users imported from LDAP have fields validated ([#923](https://github.com/pocket-id/pocket-id/issues/923)) ([4215523](https://github.com/pocket-id/pocket-id/commit/42155238b750b015b0547294f397e1e285594e3e))
* key-rotate doesn't work with database storage ([#940](https://github.com/pocket-id/pocket-id/issues/940)) ([c018f29](https://github.com/pocket-id/pocket-id/commit/c018f29ad7c61a3ef1b235b0d404a3a2024a26ca))
* list items on previous page get unselected if other items selected on next page ([6c696b4](https://github.com/pocket-id/pocket-id/commit/6c696b46c8b60b3dc4af35c9c6cf1b8e1322f4cd))
* make environment variables case insensitive where necessary ([#954](https://github.com/pocket-id/pocket-id/issues/954)) ([99f31a7](https://github.com/pocket-id/pocket-id/commit/99f31a7c26c63dec76682ddf450d88e6ee40876f)), closes [#935](https://github.com/pocket-id/pocket-id/issues/935)
* my apps card shouldn't take full width if only one item exists ([e7e53a8](https://github.com/pocket-id/pocket-id/commit/e7e53a8b8c87bee922167d24556aef3ea219b1a2))
* update localized name and description of ldap group name attribute ([#892](https://github.com/pocket-id/pocket-id/issues/892)) ([e88be7e](https://github.com/pocket-id/pocket-id/commit/e88be7e61a8aafabcae70adf9265023c50626705))
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.1...v) (2025-08-27)
### Features
* redesigned sidebar with administrative dropdown ([#881](https://github.com/pocket-id/pocket-id/issues/881)) ([096d214](https://github.com/pocket-id/pocket-id/commit/096d214a88808848dae726b0ef4c9a9987185836))
### Bug Fixes
* apps showed multiple times if user is in multiple groups ([641bbc9](https://github.com/pocket-id/pocket-id/commit/641bbc935191bad8afbfec90943fc3e9de7a0cb6))
## [](https://github.com/pocket-id/pocket-id/compare/v1.9.0...v) (2025-08-24)
### Bug Fixes
* sqlite migration drops allowed user groups ([d6d1a4c](https://github.com/pocket-id/pocket-id/commit/d6d1a4ced23886f255a9c2048d19ad3599a17f26))
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.1...v) (2025-08-24)
### Features
* support automatic db migration rollbacks ([#874](https://github.com/pocket-id/pocket-id/issues/874)) ([c114a2e](https://github.com/pocket-id/pocket-id/commit/c114a2edaae4c007c75c34c02e8b0bb011845cae))
### Bug Fixes
* don't force uuid for client id in postgres ([2ffc6ba](https://github.com/pocket-id/pocket-id/commit/2ffc6ba42af4742a13b77543142b66b3e826ab88))
* ensure SQLite has a writable temporary directory ([#876](https://github.com/pocket-id/pocket-id/issues/876)) ([1f3550c](https://github.com/pocket-id/pocket-id/commit/1f3550c9bd3aafd3bd2272ef47f3ed8736037d81))
* sort order incorrect for apps when using postgres ([d0392d2](https://github.com/pocket-id/pocket-id/commit/d0392d25edcaa5f3c7da2aad70febf63b47763fa))
## [](https://github.com/pocket-id/pocket-id/compare/v1.8.0...v) (2025-08-24) ## [](https://github.com/pocket-id/pocket-id/compare/v1.8.0...v) (2025-08-24)

View File

@@ -3,8 +3,10 @@
package frontend package frontend
import ( import (
"bytes"
"embed" "embed"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"net/http" "net/http"
"os" "os"
@@ -12,11 +14,55 @@ import (
"time" "time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/middleware"
) )
//go:embed all:dist/* //go:embed all:dist/*
var frontendFS embed.FS var frontendFS embed.FS
// This function, created by the init() method, writes to "w" the index.html page, populating the nonce
var writeIndexFn func(w io.Writer, nonce string) error
func init() {
const scriptTag = "<script>"
// Read the index.html from the bundle
index, iErr := fs.ReadFile(frontendFS, "dist/index.html")
if iErr != nil {
panic(fmt.Errorf("failed to read index.html: %w", iErr))
}
// Get the position of the first <script> tag
idx := bytes.Index(index, []byte(scriptTag))
// Create writeIndexFn, which adds the CSP tag to the script tag if needed
writeIndexFn = func(w io.Writer, nonce string) (err error) {
// If there's no nonce, write the index as-is
if nonce == "" {
_, err = w.Write(index)
return err
}
// We have a nonce, so first write the index until the <script> tag
// Then we write the modified script tag
// Finally, the rest of the index
_, err = w.Write(index[0:idx])
if err != nil {
return err
}
_, err = w.Write([]byte(`<script nonce="` + nonce + `">`))
if err != nil {
return err
}
_, err = w.Write(index[(idx + len(scriptTag)):])
if err != nil {
return err
}
return nil
}
}
func RegisterFrontend(router *gin.Engine) 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 {
@@ -27,13 +73,39 @@ func RegisterFrontend(router *gin.Engine) error {
fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds())) fileServer := NewFileServerWithCaching(http.FS(distFS), int(cacheMaxAge.Seconds()))
router.NoRoute(func(c *gin.Context) { router.NoRoute(func(c *gin.Context) {
// Try to serve the requested file
path := strings.TrimPrefix(c.Request.URL.Path, "/") path := strings.TrimPrefix(c.Request.URL.Path, "/")
if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
// File doesn't exist, serve index.html instead if strings.HasPrefix(path, "api/") {
c.Request.URL.Path = "/" c.JSON(http.StatusNotFound, gin.H{"error": "API endpoint not found"})
return
} }
// If path is / or does not exist, serve index.html
if path == "" {
path = "index.html"
} else if _, err := fs.Stat(distFS, path); os.IsNotExist(err) {
path = "index.html"
}
if path == "index.html" {
nonce := middleware.GetCSPNonce(c)
// Do not cache the HTML shell, as it embeds a per-request nonce
c.Header("Content-Type", "text/html; charset=utf-8")
c.Header("Cache-Control", "no-store")
c.Status(http.StatusOK)
err = writeIndexFn(c.Writer, nonce)
if err != nil {
_ = c.Error(fmt.Errorf("failed to write index.html file: %w", err))
return
}
return
}
// Serve other static assets with caching
c.Request.URL.Path = "/" + path
fileServer.ServeHTTP(c.Writer, c.Request) fileServer.ServeHTTP(c.Writer, c.Request)
}) })

View File

@@ -10,6 +10,7 @@ require (
github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21 github.com/emersion/go-sasl v0.0.0-20200509203442-7bfe0ed36a21
github.com/emersion/go-smtp v0.21.3 github.com/emersion/go-smtp v0.21.3
github.com/fxamacker/cbor/v2 v2.9.0 github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-contrib/slog v1.1.0
github.com/gin-gonic/gin v1.10.1 github.com/gin-gonic/gin v1.10.1
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
@@ -29,7 +30,6 @@ require (
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.0.0-beta.8 github.com/oschwald/maxminddb-golang/v2 v2.0.0-beta.8
github.com/samber/slog-gin v1.15.1
github.com/spf13/cobra v1.9.1 github.com/spf13/cobra v1.9.1
github.com/stretchr/testify v1.10.0 github.com/stretchr/testify v1.10.0
go.opentelemetry.io/contrib/bridges/otelslog v0.12.0 go.opentelemetry.io/contrib/bridges/otelslog v0.12.0
@@ -45,6 +45,7 @@ require (
go.opentelemetry.io/otel/trace v1.37.0 go.opentelemetry.io/otel/trace v1.37.0
golang.org/x/crypto v0.41.0 golang.org/x/crypto v0.41.0
golang.org/x/image v0.30.0 golang.org/x/image v0.30.0
golang.org/x/sync v0.16.0
golang.org/x/text v0.28.0 golang.org/x/text v0.28.0
golang.org/x/time v0.12.0 golang.org/x/time v0.12.0
gorm.io/driver/postgres v1.6.0 gorm.io/driver/postgres v1.6.0
@@ -74,6 +75,8 @@ require (
github.com/go-webauthn/x v0.1.23 // indirect github.com/go-webauthn/x v0.1.23 // indirect
github.com/goccy/go-json v0.10.5 // indirect github.com/goccy/go-json v0.10.5 // indirect
github.com/golang-jwt/jwt/v5 v5.2.3 // indirect github.com/golang-jwt/jwt/v5 v5.2.3 // indirect
github.com/google/go-github/v39 v39.2.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.5 // indirect github.com/google/go-tpm v0.9.5 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect
@@ -132,7 +135,7 @@ require (
golang.org/x/arch v0.20.0 // indirect golang.org/x/arch v0.20.0 // indirect
golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect
golang.org/x/net v0.43.0 // indirect golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.16.0 // indirect golang.org/x/oauth2 v0.27.0 // indirect
golang.org/x/sys v0.35.0 // indirect golang.org/x/sys v0.35.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect

View File

@@ -56,6 +56,8 @@ github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sa
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.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
github.com/gin-contrib/slog v1.1.0 h1:K9MVNrETT6r/C3u2Aheer/gxwVeVqrGL0hXlsmv3fm4=
github.com/gin-contrib/slog v1.1.0/go.mod h1:PvNXQVXcVOAaaiJR84LV1/xlQHIaXi9ygEXyBkmjdkY=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
@@ -95,11 +97,19 @@ github.com/golang-jwt/jwt/v5 v5.2.3 h1:kkGXqQOBSDDWRhWNXTFpqGSCMyh/PLnqUvMGJPDJD
github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs= github.com/golang-migrate/migrate/v4 v4.18.3 h1:EYGkoOsvgHHfm5U/naS1RP/6PL/Xv3S4B/swMiAmDLs=
github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY= github.com/golang-migrate/migrate/v4 v4.18.3/go.mod h1:99BKpIi6ruaaXRM1A77eqZ+FWPQ3cfRa+ZVy5bmWMaY=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-github/v39 v39.2.0 h1:rNNM311XtPOz5rDdsJXAp2o8F67X9FnROXTvto3aSnQ=
github.com/google/go-github/v39 v39.2.0/go.mod h1:C1s8C5aCC9L+JXIYpJM5GYytdX52vC1bLvHEF1IhBrE=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU= github.com/google/go-tpm v0.9.5 h1:ocUmnDebX54dnW+MQWGQRbdaAcJELsa6PqZhJ48KwVU=
github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY= github.com/google/go-tpm v0.9.5/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@@ -233,8 +243,6 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/samber/slog-gin v1.15.1 h1:jsnfr+S5HQPlz9pFPA3tOmKW7wN/znyZiE6hncucrTM=
github.com/samber/slog-gin v1.15.1/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys= github.com/segmentio/asm v1.2.0 h1:9BQrFxC+YOHJlTlHGkTrFWf59nbL3XnCoFLTwDCI7ys=
github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.0/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -319,6 +327,7 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
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-20210921155107-089bfa567519/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.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -339,6 +348,7 @@ golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/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=
@@ -352,6 +362,9 @@ golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.27.0 h1:da9Vo7/tDv5RH/7nZDz1eMGS/q1Vv1N/7FCrBhI9I3M=
golang.org/x/oauth2 v0.27.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 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.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.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -385,6 +398,7 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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.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.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
@@ -406,6 +420,8 @@ golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxb
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 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=
google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a h1:nwKuGPlUAt+aR+pcrkfFRrTU1BVrSmYyYMxYbUIVHr0=
google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU= google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a/go.mod h1:3kWAYMk1I75K4vykHtKt2ycnOgpA6974V7bREqbsenU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4= google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a h1:51aaUVRocpvUOSQKM6Q7VuoaktNIaMCLuhZB6DKksq4=

View File

@@ -1,9 +1,13 @@
package bootstrap package bootstrap
import ( import (
"bytes"
"encoding/hex"
"fmt" "fmt"
"io/fs"
"log/slog"
"os" "os"
"path" "path/filepath"
"strings" "strings"
"github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/common"
@@ -13,6 +17,15 @@ import (
// initApplicationImages copies the images from the images directory to the application-images directory // initApplicationImages copies the images from the images directory to the application-images directory
func initApplicationImages() error { func initApplicationImages() error {
// Images that are built into the Pocket ID binary
builtInImageHashes := getBuiltInImageHashes()
// Previous versions of images
// If these are found, they are deleted
legacyImageHashes := imageHashMap{
"background.jpg": mustDecodeHex("138d510030ed845d1d74de34658acabff562d306476454369a60ab8ade31933f"),
}
dirPath := common.EnvConfig.UploadPath + "/application-images" dirPath := common.EnvConfig.UploadPath + "/application-images"
sourceFiles, err := resources.FS.ReadDir("images") sourceFiles, err := resources.FS.ReadDir("images")
@@ -24,15 +37,48 @@ func initApplicationImages() error {
if err != nil && !os.IsNotExist(err) { if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("failed to read directory: %w", err) return fmt.Errorf("failed to read directory: %w", err)
} }
destinationFilesMap := make(map[string]bool, len(destinationFiles))
for _, f := range destinationFiles {
name := f.Name()
destFilePath := filepath.Join(dirPath, name)
h, err := utils.CreateSha256FileHash(destFilePath)
if err != nil {
return fmt.Errorf("failed to get hash for file '%s': %w", name, err)
}
// Check if the file is a legacy one - if so, delete it
if legacyImageHashes.Contains(h) {
slog.Info("Found legacy application image that will be removed", slog.String("name", name))
err = os.Remove(destFilePath)
if err != nil {
return fmt.Errorf("failed to remove legacy file '%s': %w", name, err)
}
continue
}
// Check if the file is a built-in one and save it in the map
destinationFilesMap[getImageNameWithoutExtension(name)] = builtInImageHashes.Contains(h)
}
// 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() || imageAlreadyExists(sourceFile.Name(), destinationFiles) { // Skip if it's a directory
if sourceFile.IsDir() {
continue continue
} }
srcFilePath := path.Join("images", sourceFile.Name())
destFilePath := path.Join(dirPath, sourceFile.Name())
name := sourceFile.Name()
srcFilePath := filepath.Join("images", name)
destFilePath := filepath.Join(dirPath, name)
// Skip if there's already an image at the path
// We do not check the extension because users could have uploaded a different one
if imageAlreadyExists(sourceFile, destinationFilesMap) {
continue
}
slog.Info("Writing new application image", slog.String("name", name))
err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath) err := utils.CopyEmbeddedFileToDisk(srcFilePath, destFilePath)
if err != nil { if err != nil {
return fmt.Errorf("failed to copy file: %w", err) return fmt.Errorf("failed to copy file: %w", err)
@@ -42,25 +88,49 @@ func initApplicationImages() error {
return nil return nil
} }
func imageAlreadyExists(fileName string, destinationFiles []os.DirEntry) bool { func getBuiltInImageHashes() imageHashMap {
for _, destinationFile := range destinationFiles { return imageHashMap{
sourceFileWithoutExtension := getImageNameWithoutExtension(fileName) "background.webp": mustDecodeHex("3fc436a66d6b872b01d96a4e75046c46b5c3e2daccd51e98ecdf98fd445599ab"),
destinationFileWithoutExtension := getImageNameWithoutExtension(destinationFile.Name()) "favicon.ico": mustDecodeHex("70f9c4b6bd4781ade5fc96958b1267511751e91957f83c2354fb880b35ec890a"),
"logo.svg": mustDecodeHex("f1e60707df9784152ce0847e3eb59cb68b9015f918ff160376c27ebff1eda796"),
"logoDark.svg": mustDecodeHex("0421a8d93714bacf54c78430f1db378fd0d29565f6de59b6a89090d44a82eb16"),
"logoLight.svg": mustDecodeHex("6d42c88cf6668f7e57c4f2a505e71ecc8a1e0a27534632aa6adec87b812d0bb0"),
}
}
if sourceFileWithoutExtension == destinationFileWithoutExtension { type imageHashMap map[string][]byte
func (m imageHashMap) Contains(target []byte) bool {
if len(target) == 0 {
return false
}
for _, h := range m {
if bytes.Equal(h, target) {
return true return true
} }
} }
return false return false
} }
func imageAlreadyExists(sourceFile fs.DirEntry, destinationFiles map[string]bool) bool {
sourceFileWithoutExtension := getImageNameWithoutExtension(sourceFile.Name())
_, ok := destinationFiles[sourceFileWithoutExtension]
return ok
}
func getImageNameWithoutExtension(fileName string) string { func getImageNameWithoutExtension(fileName string) string {
idx := strings.LastIndexByte(fileName, '.') idx := strings.LastIndexByte(fileName, '.')
if idx < 1 { if idx < 1 {
// No dot found, or fileName starts with a dot // No dot found, or fileName starts with a dot
return fileName return fileName
} }
return fileName[:idx] return fileName[:idx]
} }
func mustDecodeHex(str string) []byte {
b, err := hex.DecodeString(str)
if err != nil {
panic(err)
}
return b
}

View File

@@ -0,0 +1,61 @@
package bootstrap
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
func TestGetBuiltInImageData(t *testing.T) {
// Get the built-in image data map
builtInImages := getBuiltInImageHashes()
// Read the actual images directory from disk
imagesDir := filepath.Join("..", "..", "resources", "images")
actualFiles, err := os.ReadDir(imagesDir)
require.NoError(t, err, "Failed to read images directory")
// Create a map of actual files for comparison
actualFilesMap := make(map[string]struct{})
// Validate each actual file exists in the built-in data with correct hash
for _, file := range actualFiles {
fileName := file.Name()
if file.IsDir() || strings.HasPrefix(fileName, ".") {
continue
}
actualFilesMap[fileName] = struct{}{}
// Check if the file exists in the built-in data
builtInHash, exists := builtInImages[fileName]
assert.True(t, exists, "File %s exists in images directory but not in getBuiltInImageData map", fileName)
if !exists {
continue
}
filePath := filepath.Join(imagesDir, fileName)
// Validate SHA256 hash
actualHash, err := utils.CreateSha256FileHash(filePath)
require.NoError(t, err, "Failed to compute hash for %s", fileName)
assert.Equal(t, actualHash, builtInHash, "SHA256 hash mismatch for file %s", fileName)
}
// Ensure the built-in data doesn't have extra files that don't exist in the directory
for fileName := range builtInImages {
_, exists := actualFilesMap[fileName]
assert.True(t, exists, "File %s exists in getBuiltInImageData map but not in images directory", fileName)
}
// Ensure we have at least some files (sanity check)
assert.NotEmpty(t, actualFilesMap, "Images directory should contain at least one file")
assert.Len(t, actualFilesMap, len(builtInImages), "Number of files in directory should match number in built-in data map")
}

View File

@@ -5,6 +5,8 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"net/url" "net/url"
"os"
"path/filepath"
"strings" "strings"
"time" "time"
@@ -13,6 +15,7 @@ import (
"github.com/golang-migrate/migrate/v4/database" "github.com/golang-migrate/migrate/v4/database"
postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres" postgresMigrate "github.com/golang-migrate/migrate/v4/database/postgres"
sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3"
_ "github.com/golang-migrate/migrate/v4/source/github"
"github.com/golang-migrate/migrate/v4/source/iofs" "github.com/golang-migrate/migrate/v4/source/iofs"
slogGorm "github.com/orandin/slog-gorm" slogGorm "github.com/orandin/slog-gorm"
"gorm.io/driver/postgres" "gorm.io/driver/postgres"
@@ -20,6 +23,7 @@ import (
gormLogger "gorm.io/gorm/logger" gormLogger "gorm.io/gorm/logger"
"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/utils"
sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite" sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
) )
@@ -38,7 +42,9 @@ func NewDatabase() (db *gorm.DB, err error) {
var driver database.Driver var driver database.Driver
switch common.EnvConfig.DbProvider { switch common.EnvConfig.DbProvider {
case common.DbProviderSqlite: case common.DbProviderSqlite:
driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{}) driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{
NoTxWrap: true,
})
case common.DbProviderPostgres: case common.DbProviderPostgres:
driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{})
default: default:
@@ -58,8 +64,9 @@ func NewDatabase() (db *gorm.DB, err error) {
} }
func migrateDatabase(driver database.Driver) error { func migrateDatabase(driver database.Driver) error {
// Use the embedded migrations // Embedded migrations via iofs
source, err := iofs.New(resources.FS, "migrations/"+string(common.EnvConfig.DbProvider)) path := "migrations/" + string(common.EnvConfig.DbProvider)
source, err := iofs.New(resources.FS, path)
if err != nil { if err != nil {
return fmt.Errorf("failed to create embedded migration source: %w", err) return fmt.Errorf("failed to create embedded migration source: %w", err)
} }
@@ -69,14 +76,66 @@ func migrateDatabase(driver database.Driver) error {
return fmt.Errorf("failed to create migration instance: %w", err) return fmt.Errorf("failed to create migration instance: %w", err)
} }
err = m.Up() requiredVersion, err := getRequiredMigrationVersion(path)
if err != nil && !errors.Is(err, migrate.ErrNoChange) { if err != nil {
return fmt.Errorf("failed to apply migrations: %w", err) return fmt.Errorf("failed to get last migration version: %w", err)
} }
currentVersion, _, _ := m.Version()
if currentVersion > requiredVersion {
slog.Warn("Database version is newer than the application supports, possible downgrade detected", slog.Uint64("db_version", uint64(currentVersion)), slog.Uint64("app_version", uint64(requiredVersion)))
if !common.EnvConfig.AllowDowngrade {
return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion)
}
slog.Info("Fetching migrations from GitHub to handle possible downgrades")
return migrateDatabaseFromGitHub(driver, requiredVersion)
}
if err := m.Migrate(requiredVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply embedded migrations: %w", err)
}
return nil return nil
} }
func migrateDatabaseFromGitHub(driver database.Driver, version uint) error {
srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider)
m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver)
if err != nil {
return fmt.Errorf("failed to create GitHub migration instance: %w", err)
}
if err := m.Migrate(version); err != nil && !errors.Is(err, migrate.ErrNoChange) {
return fmt.Errorf("failed to apply GitHub migrations: %w", err)
}
return nil
}
// getRequiredMigrationVersion reads the embedded migration files and returns the highest version number found.
func getRequiredMigrationVersion(path string) (uint, error) {
entries, err := resources.FS.ReadDir(path)
if err != nil {
return 0, fmt.Errorf("failed to read migration directory: %w", err)
}
var maxVersion uint
for _, entry := range entries {
if entry.IsDir() {
continue
}
name := entry.Name()
var version uint
n, err := fmt.Sscanf(name, "%d_", &version)
if err == nil && n == 1 {
if version > maxVersion {
maxVersion = version
}
}
}
return maxVersion, nil
}
func connectDatabase() (db *gorm.DB, err error) { func connectDatabase() (db *gorm.DB, err error) {
var dialector gorm.Dialector var dialector gorm.Dialector
@@ -86,11 +145,20 @@ func connectDatabase() (db *gorm.DB, err error) {
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database") return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database")
} }
sqliteutil.RegisterSqliteFunctions() sqliteutil.RegisterSqliteFunctions()
connString, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
connString, dbPath, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString)
if err != nil { if err != nil {
return nil, err return nil, err
} }
// Before we connect, also make sure that there's a temporary folder for SQLite to write its data
err = ensureSqliteTempDir(filepath.Dir(dbPath))
if err != nil {
return nil, err
}
dialector = sqlite.Open(connString) dialector = sqlite.Open(connString)
case common.DbProviderPostgres: case common.DbProviderPostgres:
if common.EnvConfig.DbConnectionString == "" { if common.EnvConfig.DbConnectionString == "" {
@@ -120,7 +188,7 @@ func connectDatabase() (db *gorm.DB, err error) {
return nil, err return nil, err
} }
func parseSqliteConnectionString(connString string) (string, error) { func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, err error) {
if !strings.HasPrefix(connString, "file:") { if !strings.HasPrefix(connString, "file:") {
connString = "file:" + connString connString = "file:" + connString
} }
@@ -131,7 +199,7 @@ func parseSqliteConnectionString(connString string) (string, error) {
// Parse the connection string // Parse the connection string
connStringUrl, err := url.Parse(connString) connStringUrl, err := url.Parse(connString)
if err != nil { if err != nil {
return "", fmt.Errorf("failed to parse SQLite connection string: %w", err) return "", "", fmt.Errorf("failed to parse SQLite connection string: %w", err)
} }
// Convert options for the old SQLite driver to the new one // Convert options for the old SQLite driver to the new one
@@ -140,10 +208,19 @@ func parseSqliteConnectionString(connString string) (string, error) {
// Add the default and required params // Add the default and required params
err = addSqliteDefaultParameters(connStringUrl, isMemoryDB) err = addSqliteDefaultParameters(connStringUrl, isMemoryDB)
if err != nil { if err != nil {
return "", fmt.Errorf("invalid SQLite connection string: %w", err) return "", "", fmt.Errorf("invalid SQLite connection string: %w", err)
} }
return connStringUrl.String(), nil // Get the absolute path to the database
// Here, we know for a fact that the ? is present
parsedConnString = connStringUrl.String()
idx := strings.IndexRune(parsedConnString, '?')
dbPath, err = filepath.Abs(parsedConnString[len("file:"):idx])
if err != nil {
return "", "", fmt.Errorf("failed to determine absolute path to the database: %w", err)
}
return parsedConnString, dbPath, nil
} }
// The official C implementation of SQLite allows some additional properties in the connection string // The official C implementation of SQLite allows some additional properties in the connection string
@@ -296,6 +373,48 @@ func isSqliteInMemory(connString string) bool {
return len(qs["mode"]) > 0 && qs["mode"][0] == "memory" return len(qs["mode"]) > 0 && qs["mode"][0] == "memory"
} }
// ensureSqliteTempDir ensures that SQLite has a directory where it can write temporary files if needed
// The default directory may not be writable when using a container with a read-only root file system
// See: https://www.sqlite.org/tempfiles.html
func ensureSqliteTempDir(dbPath string) error {
// Per docs, SQLite tries these folders in order (excluding those that aren't applicable to us):
//
// - The SQLITE_TMPDIR environment variable
// - The TMPDIR environment variable
// - /var/tmp
// - /usr/tmp
// - /tmp
//
// Source: https://www.sqlite.org/tempfiles.html#temporary_file_storage_locations
//
// First, let's check if SQLITE_TMPDIR or TMPDIR are set, in which case we trust the user has taken care of the problem already
if os.Getenv("SQLITE_TMPDIR") != "" || os.Getenv("TMPDIR") != "" {
return nil
}
// Now, let's check if /var/tmp, /usr/tmp, or /tmp exist and are writable
for _, dir := range []string{"/var/tmp", "/usr/tmp", "/tmp"} {
ok, err := utils.IsWritableDir(dir)
if err != nil {
return fmt.Errorf("failed to check if %s is writable: %w", dir, err)
}
if ok {
// We found a folder that's writable
return nil
}
}
// If we're here, there's no temporary directory that's writable (not unusual for containers with a read-only root file system), so we set SQLITE_TMPDIR to the folder where the SQLite database is set
err := os.Setenv("SQLITE_TMPDIR", dbPath)
if err != nil {
return fmt.Errorf("failed to set SQLITE_TMPDIR environmental variable: %w", err)
}
slog.Debug("Set SQLITE_TMPDIR to the database directory", "path", dbPath)
return nil
}
func getGormLogger() gormLogger.Interface { func getGormLogger() gormLogger.Interface {
loggerOpts := make([]slogGorm.Option, 0, 5) loggerOpts := make([]slogGorm.Option, 0, 5)
loggerOpts = append(loggerOpts, loggerOpts = append(loggerOpts,
@@ -303,17 +422,18 @@ func getGormLogger() gormLogger.Interface {
slogGorm.WithErrorField("error"), slogGorm.WithErrorField("error"),
) )
if common.EnvConfig.AppEnv == "production" { if common.EnvConfig.LogLevel == "debug" {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
} else {
loggerOpts = append(loggerOpts, loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug), slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelDebug),
slogGorm.WithRecordNotFoundError(), slogGorm.WithRecordNotFoundError(),
slogGorm.WithTraceAll(), slogGorm.WithTraceAll(),
) )
} else {
loggerOpts = append(loggerOpts,
slogGorm.SetLogLevel(slogGorm.DefaultLogType, slog.LevelWarn),
slogGorm.WithIgnoreTrace(),
)
} }
return slogGorm.New(loggerOpts...) return slogGorm.New(loggerOpts...)

View File

@@ -8,6 +8,8 @@ import (
"os" "os"
"time" "time"
sloggin "github.com/gin-contrib/slog"
"github.com/lmittmann/tint" "github.com/lmittmann/tint"
"github.com/mattn/go-isatty" "github.com/mattn/go-isatty"
"go.opentelemetry.io/contrib/bridges/otelslog" "go.opentelemetry.io/contrib/bridges/otelslog"
@@ -89,28 +91,19 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error {
return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err) return fmt.Errorf("failed to initialize OpenTelemetry log exporter: %w", err)
} }
level := slog.LevelDebug level, _ := sloggin.ParseLevel(common.EnvConfig.LogLevel)
if common.EnvConfig.AppEnv == "production" {
level = slog.LevelInfo
}
// Create the handler // Create the handler
var handler slog.Handler var handler slog.Handler
switch { if common.EnvConfig.LogJSON {
case common.EnvConfig.LogJSON:
// Log as JSON if configured
handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ handler = slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: level, Level: level,
}) })
case isatty.IsTerminal(os.Stdout.Fd()): } else {
// Enable colors if we have a TTY
handler = tint.NewHandler(os.Stdout, &tint.Options{ handler = tint.NewHandler(os.Stdout, &tint.Options{
TimeFormat: time.StampMilli, TimeFormat: time.Stamp,
Level: level, Level: level,
}) NoColor: !isatty.IsTerminal(os.Stdout.Fd()),
default:
handler = slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: level,
}) })
} }

View File

@@ -12,8 +12,8 @@ import (
"strings" "strings"
"time" "time"
sloggin "github.com/gin-contrib/slog"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
sloggin "github.com/samber/slog-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/time/rate" "golang.org/x/time/rate"
"gorm.io/gorm" "gorm.io/gorm"
@@ -49,30 +49,8 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
} }
// do not log these URLs
loggerSkipPathsPrefix := []string{
"GET /application-configuration/logo",
"GET /application-configuration/background-image",
"GET /application-configuration/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r := gin.New() r := gin.New()
r.Use(sloggin.NewWithConfig(slog.Default(), sloggin.Config{ initLogger(r)
Filters: []sloggin.Filter{
func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return false
}
}
return true
},
},
}))
if !common.EnvConfig.TrustProxy { if !common.EnvConfig.TrustProxy {
_ = r.SetTrustedProxies(nil) _ = r.SetTrustedProxies(nil)
@@ -86,6 +64,7 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
// Setup global middleware // Setup global middleware
r.Use(middleware.NewCorsMiddleware().Add()) r.Use(middleware.NewCorsMiddleware().Add())
r.Use(middleware.NewCspMiddleware().Add())
r.Use(middleware.NewErrorHandlerMiddleware().Add()) r.Use(middleware.NewErrorHandlerMiddleware().Add())
err := frontend.RegisterFrontend(r) err := frontend.RegisterFrontend(r)
@@ -109,6 +88,7 @@ func initRouterInternal(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, svc.versionService)
// Add test controller in non-production environments // Add test controller in non-production environments
if common.EnvConfig.AppEnv != "production" { if common.EnvConfig.AppEnv != "production" {
@@ -198,3 +178,29 @@ func initRouterInternal(db *gorm.DB, svc *services) (utils.Service, error) {
return runFn, nil return runFn, nil
} }
func initLogger(r *gin.Engine) {
loggerSkipPathsPrefix := []string{
"GET /api/application-configuration/logo",
"GET /api/application-configuration/background-image",
"GET /api/application-configuration/favicon",
"GET /_app",
"GET /fonts",
"GET /healthz",
"HEAD /healthz",
}
r.Use(sloggin.SetLogger(
sloggin.WithLogger(func(_ *gin.Context, _ *slog.Logger) *slog.Logger {
return slog.Default()
}),
sloggin.WithSkipper(func(c *gin.Context) bool {
for _, prefix := range loggerSkipPathsPrefix {
if strings.HasPrefix(c.Request.Method+" "+c.Request.URL.String(), prefix) {
return true
}
}
return false
}),
))
}

View File

@@ -23,6 +23,7 @@ type services struct {
userGroupService *service.UserGroupService userGroupService *service.UserGroupService
ldapService *service.LdapService ldapService *service.LdapService
apiKeyService *service.ApiKeyService apiKeyService *service.ApiKeyService
versionService *service.VersionService
} }
// Initializes all services // Initializes all services
@@ -62,5 +63,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client) (sv
svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService) svc.ldapService = service.NewLdapService(db, httpClient, svc.appConfigService, svc.userService, svc.userGroupService)
svc.apiKeyService = service.NewApiKeyService(db, svc.emailService) svc.apiKeyService = service.NewApiKeyService(db, svc.emailService)
svc.versionService = service.NewVersionService(httpClient)
return svc, nil return svc, nil
} }

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"github.com/caarlos0/env/v11" "github.com/caarlos0/env/v11"
sloggin "github.com/gin-contrib/slog"
_ "github.com/joho/godotenv/autoload" _ "github.com/joho/godotenv/autoload"
) )
@@ -27,19 +28,21 @@ const (
DbProviderPostgres DbProvider = "postgres" DbProviderPostgres DbProvider = "postgres"
MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz" MaxMindGeoLiteCityUrl string = "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=%s&suffix=tar.gz"
defaultSqliteConnString string = "data/pocket-id.db" defaultSqliteConnString string = "data/pocket-id.db"
AppUrl string = "http://localhost:1411"
) )
type EnvConfigSchema struct { type EnvConfigSchema struct {
AppEnv string `env:"APP_ENV"` AppEnv string `env:"APP_ENV" options:"toLower"`
AppURL string `env:"APP_URL"` LogLevel string `env:"LOG_LEVEL" options:"toLower"`
DbProvider DbProvider `env:"DB_PROVIDER"` AppURL string `env:"APP_URL" options:"toLower"`
DbProvider DbProvider `env:"DB_PROVIDER" options:"toLower"`
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"` DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
UploadPath string `env:"UPLOAD_PATH"` UploadPath string `env:"UPLOAD_PATH"`
KeysPath string `env:"KEYS_PATH"` KeysPath string `env:"KEYS_PATH"`
KeysStorage string `env:"KEYS_STORAGE"` KeysStorage string `env:"KEYS_STORAGE"`
EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"` EncryptionKey []byte `env:"ENCRYPTION_KEY" options:"file"`
Port string `env:"PORT"` Port string `env:"PORT"`
Host string `env:"HOST"` Host string `env:"HOST" options:"toLower"`
UnixSocket string `env:"UNIX_SOCKET"` UnixSocket string `env:"UNIX_SOCKET"`
UnixSocketMode string `env:"UNIX_SOCKET_MODE"` UnixSocketMode string `env:"UNIX_SOCKET_MODE"`
MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"` MaxMindLicenseKey string `env:"MAXMIND_LICENSE_KEY" options:"file"`
@@ -52,6 +55,8 @@ type EnvConfigSchema struct {
LogJSON bool `env:"LOG_JSON"` LogJSON bool `env:"LOG_JSON"`
TrustProxy bool `env:"TRUST_PROXY"` TrustProxy bool `env:"TRUST_PROXY"`
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"` AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
AllowDowngrade bool `env:"ALLOW_DOWNGRADE"`
InternalAppURL string `env:"INTERNAL_APP_URL"`
} }
var EnvConfig = defaultConfig() var EnvConfig = defaultConfig()
@@ -67,13 +72,14 @@ func init() {
func defaultConfig() EnvConfigSchema { func defaultConfig() EnvConfigSchema {
return EnvConfigSchema{ return EnvConfigSchema{
AppEnv: "production", AppEnv: "production",
LogLevel: "info",
DbProvider: "sqlite", DbProvider: "sqlite",
DbConnectionString: "", DbConnectionString: "",
UploadPath: "data/uploads", UploadPath: "data/uploads",
KeysPath: "data/keys", KeysPath: "data/keys",
KeysStorage: "", // "database" or "file" KeysStorage: "", // "database" or "file"
EncryptionKey: nil, EncryptionKey: nil,
AppURL: "http://localhost:1411", AppURL: AppUrl,
Port: "1411", Port: "1411",
Host: "0.0.0.0", Host: "0.0.0.0",
UnixSocket: "", UnixSocket: "",
@@ -87,6 +93,8 @@ func defaultConfig() EnvConfigSchema {
TracingEnabled: false, TracingEnabled: false,
TrustProxy: false, TrustProxy: false,
AnalyticsDisabled: false, AnalyticsDisabled: false,
AllowDowngrade: false,
InternalAppURL: "",
} }
} }
@@ -104,26 +112,40 @@ func parseEnvConfig() error {
return fmt.Errorf("error parsing env config: %w", err) return fmt.Errorf("error parsing env config: %w", err)
} }
err = resolveFileBasedEnvVariables(&EnvConfig) err = prepareEnvConfig(&EnvConfig)
if err != nil {
return fmt.Errorf("error preparing env config: %w", err)
}
err = validateEnvConfig(&EnvConfig)
if err != nil { if err != nil {
return err return err
} }
// Validate the environment variables return nil
switch EnvConfig.DbProvider {
}
// validateEnvConfig checks the EnvConfig for required fields and valid values
func validateEnvConfig(config *EnvConfigSchema) error {
if _, err := sloggin.ParseLevel(config.LogLevel); err != nil {
return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'")
}
switch config.DbProvider {
case DbProviderSqlite: case DbProviderSqlite:
if EnvConfig.DbConnectionString == "" { if config.DbConnectionString == "" {
EnvConfig.DbConnectionString = defaultSqliteConnString config.DbConnectionString = defaultSqliteConnString
} }
case DbProviderPostgres: case DbProviderPostgres:
if EnvConfig.DbConnectionString == "" { if config.DbConnectionString == "" {
return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") return errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database")
} }
default: default:
return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'") return errors.New("invalid DB_PROVIDER value. Must be 'sqlite' or 'postgres'")
} }
parsedAppUrl, err := url.Parse(EnvConfig.AppURL) parsedAppUrl, err := url.Parse(config.AppURL)
if err != nil { if err != nil {
return errors.New("APP_URL is not a valid URL") return errors.New("APP_URL is not a valid URL")
} }
@@ -131,25 +153,39 @@ func parseEnvConfig() error {
return errors.New("APP_URL must not contain a path") return errors.New("APP_URL must not contain a path")
} }
switch EnvConfig.KeysStorage { // Derive INTERNAL_APP_URL from APP_URL if not set; validate only when provided
if config.InternalAppURL == "" {
config.InternalAppURL = config.AppURL
} else {
parsedInternalAppUrl, err := url.Parse(config.InternalAppURL)
if err != nil {
return errors.New("INTERNAL_APP_URL is not a valid URL")
}
if parsedInternalAppUrl.Path != "" {
return errors.New("INTERNAL_APP_URL must not contain a path")
}
}
switch config.KeysStorage {
// KeysStorage defaults to "file" if empty // KeysStorage defaults to "file" if empty
case "": case "":
EnvConfig.KeysStorage = "file" config.KeysStorage = "file"
case "database": case "database":
if EnvConfig.EncryptionKey == nil { if config.EncryptionKey == nil {
return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database") return errors.New("ENCRYPTION_KEY must be non-empty when KEYS_STORAGE is database")
} }
case "file": case "file":
// All good, these are valid values // All good, these are valid values
default: default:
return fmt.Errorf("invalid value for KEYS_STORAGE: %s", EnvConfig.KeysStorage) return fmt.Errorf("invalid value for KEYS_STORAGE: %s", config.KeysStorage)
} }
return nil return nil
} }
// resolveFileBasedEnvVariables uses reflection to automatically resolve file-based secrets // prepareEnvConfig processes special options for EnvConfig fields
func resolveFileBasedEnvVariables(config *EnvConfigSchema) error { func prepareEnvConfig(config *EnvConfigSchema) error {
val := reflect.ValueOf(config).Elem() val := reflect.ValueOf(config).Elem()
typ := val.Type() typ := val.Type()
@@ -157,48 +193,65 @@ func resolveFileBasedEnvVariables(config *EnvConfigSchema) error {
field := val.Field(i) field := val.Field(i)
fieldType := typ.Field(i) fieldType := typ.Field(i)
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
continue
}
// Only process fields with the "options" tag set to "file"
optionsTag := fieldType.Tag.Get("options") optionsTag := fieldType.Tag.Get("options")
if optionsTag != "file" { options := strings.Split(optionsTag, ",")
continue
}
// Only process fields with the "env" tag for _, option := range options {
envTag := fieldType.Tag.Get("env") switch option {
if envTag == "" { case "toLower":
continue if field.Kind() == reflect.String {
} field.SetString(strings.ToLower(field.String()))
}
envVarName := envTag case "file":
if commaIndex := len(envTag); commaIndex > 0 { err := resolveFileBasedEnvVariable(field, fieldType)
envVarName = envTag[:commaIndex] if err != nil {
} return err
}
// If the file environment variable is not set, skip }
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
continue
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
} }
} }
return nil return nil
} }
// resolveFileBasedEnvVariable checks if an environment variable with the suffix "_FILE" is set,
// reads the content of the file specified by that variable, and sets the corresponding field's value.
func resolveFileBasedEnvVariable(field reflect.Value, fieldType reflect.StructField) error {
// Only process string and []byte fields
isString := field.Kind() == reflect.String
isByteSlice := field.Kind() == reflect.Slice && field.Type().Elem().Kind() == reflect.Uint8
if !isString && !isByteSlice {
return nil
}
// Only process fields with the "env" tag
envTag := fieldType.Tag.Get("env")
if envTag == "" {
return nil
}
envVarName := envTag
if commaIndex := len(envTag); commaIndex > 0 {
envVarName = envTag[:commaIndex]
}
// If the file environment variable is not set, skip
envVarFileName := envVarName + "_FILE"
envVarFileValue := os.Getenv(envVarFileName)
if envVarFileValue == "" {
return nil
}
fileContent, err := os.ReadFile(envVarFileValue)
if err != nil {
return fmt.Errorf("failed to read file for env var %s: %w", envVarFileName, err)
}
if isString {
field.SetString(strings.TrimSpace(string(fileContent)))
} else {
field.SetBytes(fileContent)
}
return nil
}

View File

@@ -17,18 +17,19 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should parse valid SQLite config correctly", func(t *testing.T) { t.Run("should parse valid SQLite config correctly", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "SQLITE") // should be lowercased automatically
t.Setenv("DB_CONNECTION_STRING", "file:test.db") t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("APP_URL", "http://localhost:3000") t.Setenv("APP_URL", "HTTP://LOCALHOST:3000")
err := parseEnvConfig() err := parseEnvConfig()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider) assert.Equal(t, DbProviderSqlite, EnvConfig.DbProvider)
assert.Equal(t, "http://localhost:3000", EnvConfig.AppURL)
}) })
t.Run("should parse valid Postgres config correctly", func(t *testing.T) { t.Run("should parse valid Postgres config correctly", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_PROVIDER", "POSTGRES")
t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db") t.Setenv("DB_CONNECTION_STRING", "postgres://user:pass@localhost/db")
t.Setenv("APP_URL", "https://example.com") t.Setenv("APP_URL", "https://example.com")
@@ -51,7 +52,6 @@ func TestParseEnvConfig(t *testing.T) {
t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) { t.Run("should set default SQLite connection string when DB_CONNECTION_STRING is empty", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "") // Explicitly empty
t.Setenv("APP_URL", "http://localhost:3000") t.Setenv("APP_URL", "http://localhost:3000")
err := parseEnvConfig() err := parseEnvConfig()
@@ -91,6 +91,28 @@ func TestParseEnvConfig(t *testing.T) {
assert.ErrorContains(t, err, "APP_URL must not contain a path") assert.ErrorContains(t, err, "APP_URL must not contain a path")
}) })
t.Run("should fail with invalid INTERNAL_APP_URL", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "€://not-a-valid-url")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL is not a valid URL")
})
t.Run("should fail when INTERNAL_APP_URL contains path", func(t *testing.T) {
EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite")
t.Setenv("DB_CONNECTION_STRING", "file:test.db")
t.Setenv("INTERNAL_APP_URL", "http://localhost:3000/path")
err := parseEnvConfig()
require.Error(t, err)
assert.ErrorContains(t, err, "INTERNAL_APP_URL must not contain a path")
})
t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) { t.Run("should default KEYS_STORAGE to 'file' when empty", func(t *testing.T) {
EnvConfig = defaultConfig() EnvConfig = defaultConfig()
t.Setenv("DB_PROVIDER", "sqlite") t.Setenv("DB_PROVIDER", "sqlite")
@@ -170,25 +192,25 @@ func TestParseEnvConfig(t *testing.T) {
t.Setenv("DB_PROVIDER", "postgres") t.Setenv("DB_PROVIDER", "postgres")
t.Setenv("DB_CONNECTION_STRING", "postgres://test") t.Setenv("DB_CONNECTION_STRING", "postgres://test")
t.Setenv("APP_URL", "https://prod.example.com") t.Setenv("APP_URL", "https://prod.example.com")
t.Setenv("APP_ENV", "staging") t.Setenv("APP_ENV", "STAGING")
t.Setenv("UPLOAD_PATH", "/custom/uploads") t.Setenv("UPLOAD_PATH", "/custom/uploads")
t.Setenv("KEYS_PATH", "/custom/keys") t.Setenv("KEYS_PATH", "/custom/keys")
t.Setenv("PORT", "8080") t.Setenv("PORT", "8080")
t.Setenv("HOST", "127.0.0.1") t.Setenv("HOST", "LOCALHOST")
t.Setenv("UNIX_SOCKET", "/tmp/app.sock") t.Setenv("UNIX_SOCKET", "/tmp/app.sock")
t.Setenv("MAXMIND_LICENSE_KEY", "test-license") t.Setenv("MAXMIND_LICENSE_KEY", "test-license")
t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb") t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb")
err := parseEnvConfig() err := parseEnvConfig()
require.NoError(t, err) require.NoError(t, err)
assert.Equal(t, "staging", EnvConfig.AppEnv) assert.Equal(t, "staging", EnvConfig.AppEnv) // lowercased
assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath) assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath)
assert.Equal(t, "8080", EnvConfig.Port) assert.Equal(t, "8080", EnvConfig.Port)
assert.Equal(t, "127.0.0.1", EnvConfig.Host) assert.Equal(t, "localhost", EnvConfig.Host) // lowercased
}) })
} }
func TestResolveFileBasedEnvVariables(t *testing.T) { func TestPrepareEnvConfig_FileBasedAndToLower(t *testing.T) {
// Create temporary directory for test files // Create temporary directory for test files
tempDir := t.TempDir() tempDir := t.TempDir()
@@ -203,103 +225,34 @@ func TestResolveFileBasedEnvVariables(t *testing.T) {
err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600) err = os.WriteFile(dbConnFile, []byte(dbConnContent), 0600)
require.NoError(t, err) require.NoError(t, err)
// Create a binary file for testing binary data handling
binaryKeyFile := tempDir + "/binary_key.bin" binaryKeyFile := tempDir + "/binary_key.bin"
binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x10} binaryKeyContent := []byte{0x01, 0x02, 0x03, 0x04}
err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600) err = os.WriteFile(binaryKeyFile, binaryKeyContent, 0600)
require.NoError(t, err) require.NoError(t, err)
t.Run("should read file content for fields with options:file tag", func(t *testing.T) { t.Run("should process toLower and file options", func(t *testing.T) {
config := defaultConfig() config := defaultConfig()
config.AppEnv = "STAGING"
config.Host = "LOCALHOST"
// Set environment variables pointing to files
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile) t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile) t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config) err := prepareEnvConfig(&config)
require.NoError(t, err) require.NoError(t, err)
// Verify file contents were read correctly assert.Equal(t, "staging", config.AppEnv)
assert.Equal(t, "localhost", config.Host)
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey) assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString) assert.Equal(t, dbConnContent, config.DbConnectionString)
}) })
t.Run("should skip fields without options:file tag", func(t *testing.T) {
config := defaultConfig()
originalAppURL := config.AppURL
// Set a file for a field that doesn't have options:file tag
t.Setenv("APP_URL_FILE", "/tmp/nonexistent.txt")
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// AppURL should remain unchanged
assert.Equal(t, originalAppURL, config.AppURL)
})
t.Run("should skip non-string fields", func(t *testing.T) {
// This test verifies that non-string fields are skipped
// We test this indirectly by ensuring the function doesn't error
// when processing the actual EnvConfigSchema which has bool fields
config := defaultConfig()
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
})
t.Run("should skip when _FILE environment variable is not set", func(t *testing.T) {
config := defaultConfig()
originalEncryptionKey := config.EncryptionKey
// Don't set ENCRYPTION_KEY_FILE environment variable
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// EncryptionKey should remain unchanged
assert.Equal(t, originalEncryptionKey, config.EncryptionKey)
})
t.Run("should handle multiple file-based variables simultaneously", func(t *testing.T) {
config := defaultConfig()
// Set multiple file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
t.Setenv("DB_CONNECTION_STRING_FILE", dbConnFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// All should be resolved correctly
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, dbConnContent, config.DbConnectionString)
})
t.Run("should handle mixed file and non-file environment variables", func(t *testing.T) {
config := defaultConfig()
// Set both file and non-file environment variables
t.Setenv("ENCRYPTION_KEY_FILE", encryptionKeyFile)
err := resolveFileBasedEnvVariables(&config)
require.NoError(t, err)
// File-based should be resolved, others should remain as set by env parser
assert.Equal(t, []byte(encryptionKeyContent), config.EncryptionKey)
assert.Equal(t, "http://localhost:1411", config.AppURL)
})
t.Run("should handle binary data correctly", func(t *testing.T) { t.Run("should handle binary data correctly", func(t *testing.T) {
config := defaultConfig() config := defaultConfig()
// Set environment variable pointing to binary file
t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile) t.Setenv("ENCRYPTION_KEY_FILE", binaryKeyFile)
err := resolveFileBasedEnvVariables(&config) err := prepareEnvConfig(&config)
require.NoError(t, err) require.NoError(t, err)
// Verify binary data was read correctly without corruption
assert.Equal(t, binaryKeyContent, config.EncryptionKey) assert.Equal(t, binaryKeyContent, config.EncryptionKey)
}) })
} }

View File

@@ -828,7 +828,7 @@ func (oc *OidcController) getClientPreviewHandler(c *gin.Context) {
return return
} }
preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, scopes) preview, err := oc.oidcService.GetClientPreview(c.Request.Context(), clientID, userID, strings.Split(scopes, " "))
if err != nil { if err != nil {
_ = c.Error(err) _ = c.Error(err)
return return

View File

@@ -0,0 +1,40 @@
package controller
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/pocket-id/pocket-id/backend/internal/service"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
// NewVersionController registers version-related routes.
func NewVersionController(group *gin.RouterGroup, versionService *service.VersionService) {
vc := &VersionController{versionService: versionService}
group.GET("/version/latest", vc.getLatestVersionHandler)
}
type VersionController struct {
versionService *service.VersionService
}
// getLatestVersionHandler godoc
// @Summary Get latest available version of Pocket ID
// @Tags Version
// @Produce json
// @Success 200 {object} map[string]string "Latest version information"
// @Router /api/version/latest [get]
func (vc *VersionController) getLatestVersionHandler(c *gin.Context) {
tag, err := vc.versionService.GetLatestVersion(c.Request.Context())
if err != nil {
_ = c.Error(err)
return
}
utils.SetCacheControlHeader(c, 5*time.Minute, 15*time.Minute)
c.JSON(http.StatusOK, gin.H{
"latestVersion": tag,
})
}

View File

@@ -67,6 +67,9 @@ func (wkc *WellKnownController) openIDConfigurationHandler(c *gin.Context) {
func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) { func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
appUrl := common.EnvConfig.AppURL appUrl := common.EnvConfig.AppURL
internalAppUrl := common.EnvConfig.InternalAppURL
alg, err := wkc.jwtService.GetKeyAlg() alg, err := wkc.jwtService.GetKeyAlg()
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to get key algorithm: %w", err) return nil, fmt.Errorf("failed to get key algorithm: %w", err)
@@ -74,13 +77,13 @@ func (wkc *WellKnownController) computeOIDCConfiguration() ([]byte, error) {
config := map[string]any{ config := map[string]any{
"issuer": appUrl, "issuer": appUrl,
"authorization_endpoint": appUrl + "/authorize", "authorization_endpoint": appUrl + "/authorize",
"token_endpoint": appUrl + "/api/oidc/token", "token_endpoint": internalAppUrl + "/api/oidc/token",
"userinfo_endpoint": appUrl + "/api/oidc/userinfo", "userinfo_endpoint": internalAppUrl + "/api/oidc/userinfo",
"end_session_endpoint": appUrl + "/api/oidc/end-session", "end_session_endpoint": appUrl + "/api/oidc/end-session",
"introspection_endpoint": appUrl + "/api/oidc/introspect", "introspection_endpoint": internalAppUrl + "/api/oidc/introspect",
"device_authorization_endpoint": appUrl + "/api/oidc/device/authorize", "device_authorization_endpoint": appUrl + "/api/oidc/device/authorize",
"jwks_uri": appUrl + "/.well-known/jwks.json", "jwks_uri": internalAppUrl + "/.well-known/jwks.json",
"grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode}, "grant_types_supported": []string{service.GrantTypeAuthorizationCode, service.GrantTypeRefreshToken, service.GrantTypeDeviceCode, service.GrantTypeClientCredentials},
"scopes_supported": []string{"openid", "profile", "email", "groups"}, "scopes_supported": []string{"openid", "profile", "email", "groups"},
"claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"}, "claims_supported": []string{"sub", "given_name", "family_name", "name", "email", "email_verified", "preferred_username", "picture", "groups"},
"response_types_supported": []string{"code", "id_token"}, "response_types_supported": []string{"code", "id_token"},

View File

@@ -41,6 +41,7 @@ type AppConfigUpdateDto struct {
LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"` LdapAttributeUserEmail string `json:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"` LdapAttributeUserFirstName string `json:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"` LdapAttributeUserLastName string `json:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName string `json:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"` LdapAttributeUserProfilePicture string `json:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"` LdapAttributeGroupMember string `json:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier string `json:"ldapAttributeGroupUniqueIdentifier"`

View File

@@ -31,8 +31,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"` CallbackURLs []string `json:"callbackURLs" binding:"omitempty,dive,callback_url"`
LogoutCallbackURLs []string `json:"logoutCallbackURLs"` 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"`
@@ -87,6 +87,7 @@ type OidcCreateTokensDto struct {
RefreshToken string `form:"refresh_token"` RefreshToken string `form:"refresh_token"`
ClientAssertion string `form:"client_assertion"` ClientAssertion string `form:"client_assertion"`
ClientAssertionType string `form:"client_assertion_type"` ClientAssertionType string `form:"client_assertion_type"`
Resource string `form:"resource"`
} }
type OidcIntrospectDto struct { type OidcIntrospectDto struct {

View File

@@ -1,6 +1,9 @@
package dto package dto
import ( import (
"errors"
"github.com/gin-gonic/gin/binding"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
) )
@@ -9,7 +12,8 @@ type UserDto struct {
Username string `json:"username"` Username string `json:"username"`
Email string `json:"email" ` Email string `json:"email" `
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName *string `json:"lastName"`
DisplayName string `json:"displayName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"` Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
@@ -19,14 +23,26 @@ type UserDto struct {
} }
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"` Username string `json:"username" binding:"required,username,min=2,max=50" unorm:"nfc"`
Email string `json:"email" binding:"required,email" unorm:"nfc"` Email string `json:"email" binding:"required,email" unorm:"nfc"`
FirstName string `json:"firstName" binding:"required,min=1,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"`
IsAdmin bool `json:"isAdmin"` DisplayName string `json:"displayName" binding:"required,max=100" unorm:"nfc"`
Locale *string `json:"locale"` IsAdmin bool `json:"isAdmin"`
Disabled bool `json:"disabled"` Locale *string `json:"locale"`
LdapID string `json:"-"` Disabled bool `json:"disabled"`
LdapID string `json:"-"`
}
func (u UserCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(u)
} }
type OneTimeAccessTokenCreateDto struct { type OneTimeAccessTokenCreateDto struct {

View File

@@ -0,0 +1,104 @@
package dto
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestUserCreateDto_Validate(t *testing.T) {
testCases := []struct {
name string
input UserCreateDto
wantErr string
}{
{
name: "valid input",
input: UserCreateDto{
Username: "testuser",
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "",
},
{
name: "missing username",
input: UserCreateDto{
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'required' tag",
},
{
name: "missing display name",
input: UserCreateDto{
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
},
wantErr: "Field validation for 'DisplayName' failed on the 'required' tag",
},
{
name: "username contains invalid characters",
input: UserCreateDto{
Username: "test/ser",
Email: "test@example.com",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Username' failed on the 'username' tag",
},
{
name: "invalid email",
input: UserCreateDto{
Username: "testuser",
Email: "not-an-email",
FirstName: "John",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'Email' failed on the 'email' tag",
},
{
name: "first name too short",
input: UserCreateDto{
Username: "testuser",
Email: "test@example.com",
FirstName: "",
LastName: "Doe",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'FirstName' failed on the 'required' tag",
},
{
name: "last name too long",
input: UserCreateDto{
Username: "testuser",
Email: "test@example.com",
FirstName: "John",
LastName: "abcdfghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
DisplayName: "John Doe",
},
wantErr: "Field validation for 'LastName' failed on the 'max' tag",
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.input.Validate()
if tc.wantErr == "" {
require.NoError(t, err)
return
}
require.Error(t, err)
require.ErrorContains(t, err, tc.wantErr)
})
}
}

View File

@@ -1,6 +1,9 @@
package dto package dto
import ( import (
"errors"
"github.com/gin-gonic/gin/binding"
datatype "github.com/pocket-id/pocket-id/backend/internal/model/types" datatype "github.com/pocket-id/pocket-id/backend/internal/model/types"
) )
@@ -39,6 +42,17 @@ type UserGroupCreateDto struct {
LdapID string `json:"-"` LdapID string `json:"-"`
} }
func (g UserGroupCreateDto) Validate() error {
e, ok := binding.Validator.Engine().(interface {
Struct(s any) error
})
if !ok {
return errors.New("validator does not implement the expected interface")
}
return e.Struct(g)
}
type UserGroupUpdateUsersDto struct { type UserGroupUpdateUsersDto struct {
UserIDs []string `json:"userIds" binding:"required"` UserIDs []string `json:"userIds" binding:"required"`
} }

View File

@@ -1,7 +1,9 @@
package dto package dto
import ( import (
"net/url"
"regexp" "regexp"
"strings"
"time" "time"
"github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils"
@@ -10,43 +12,76 @@ import (
"github.com/go-playground/validator/v10" "github.com/go-playground/validator/v10"
) )
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
func init() { func init() {
v := binding.Validator.Engine().(*validator.Validate) v := binding.Validator.Engine().(*validator.Validate)
// [a-zA-Z0-9] : The username must start with an alphanumeric character
// [a-zA-Z0-9_.@-]* : The rest of the username can contain alphanumeric characters, dots, underscores, hyphens, and "@" symbols
// [a-zA-Z0-9]$ : The username must end with an alphanumeric character
var validateUsernameRegex = regexp.MustCompile("^[a-zA-Z0-9][a-zA-Z0-9_.@-]*[a-zA-Z0-9]$")
var validateClientIDRegex = regexp.MustCompile("^[a-zA-Z0-9._-]+$")
// Maximum allowed value for TTLs // Maximum allowed value for TTLs
const maxTTL = 31 * 24 * time.Hour const maxTTL = 31 * 24 * time.Hour
// Errors here are development-time ones if err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool {
err := v.RegisterValidation("username", func(fl validator.FieldLevel) bool { return ValidateUsername(fl.Field().String())
return validateUsernameRegex.MatchString(fl.Field().String()) }); err != nil {
})
if err != nil {
panic("Failed to register custom validation for username: " + err.Error()) panic("Failed to register custom validation for username: " + err.Error())
} }
err = v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool { if err := v.RegisterValidation("client_id", func(fl validator.FieldLevel) bool {
return validateClientIDRegex.MatchString(fl.Field().String()) return ValidateClientID(fl.Field().String())
}) }); err != nil {
if err != nil {
panic("Failed to register custom validation for client_id: " + err.Error()) panic("Failed to register custom validation for client_id: " + err.Error())
} }
err = v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool { if err := v.RegisterValidation("ttl", func(fl validator.FieldLevel) bool {
ttl, ok := fl.Field().Interface().(utils.JSONDuration) ttl, ok := fl.Field().Interface().(utils.JSONDuration)
if !ok { if !ok {
return false return false
} }
// Allow zero, which means the field wasn't set // Allow zero, which means the field wasn't set
return ttl.Duration == 0 || ttl.Duration > time.Second && ttl.Duration <= maxTTL return ttl.Duration == 0 || (ttl.Duration > time.Second && ttl.Duration <= maxTTL)
}) }); err != nil {
if err != nil {
panic("Failed to register custom validation for ttl: " + err.Error()) 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())
}
}
// ValidateUsername validates username inputs
func ValidateUsername(username string) bool {
return validateUsernameRegex.MatchString(username)
}
// ValidateClientID validates client ID inputs
func ValidateClientID(clientID string) bool {
return validateClientIDRegex.MatchString(clientID)
}
// ValidateCallbackURL validates callback URLs with support for wildcards
func ValidateCallbackURL(raw string) bool {
if raw == "*" {
return true
}
// Replace all '*' with 'x' to check if the rest is still a valid URI
test := strings.ReplaceAll(raw, "*", "x")
u, err := url.Parse(test)
if err != nil {
return false
}
if !u.IsAbs() {
return false
}
return true
} }

View File

@@ -0,0 +1,58 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidateUsername(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "user123", true},
{"valid with dot", "user.name", true},
{"valid with underscore", "user_name", true},
{"valid with hyphen", "user-name", true},
{"valid with at", "user@name", true},
{"starts with symbol", ".username", false},
{"ends with non-alphanumeric", "username-", false},
{"contains space", "user name", false},
{"empty", "", false},
{"only special chars", "-._@", false},
{"valid long", "a1234567890_b.c-d@e", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateUsername(tt.input))
})
}
}
func TestValidateClientID(t *testing.T) {
tests := []struct {
name string
input string
expected bool
}{
{"valid simple", "client123", true},
{"valid with dot", "client.id", true},
{"valid with underscore", "client_id", true},
{"valid with hyphen", "client-id", true},
{"valid with all", "client.id-123_abc", true},
{"contains space", "client id", false},
{"contains at", "client@id", false},
{"empty", "", false},
{"only special chars", "-._", true},
{"invalid char", "client!id", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.expected, ValidateClientID(tt.input))
})
}
}

View File

@@ -0,0 +1,53 @@
package middleware
import (
"crypto/rand"
"encoding/base64"
"github.com/gin-gonic/gin"
)
// CspMiddleware sets a Content Security Policy header and, when possible,
// includes a per-request nonce for inline scripts.
type CspMiddleware struct{}
func NewCspMiddleware() *CspMiddleware { return &CspMiddleware{} }
// GetCSPNonce returns the CSP nonce generated for this request, if any.
func GetCSPNonce(c *gin.Context) string {
if v, ok := c.Get("csp_nonce"); ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}
func (m *CspMiddleware) Add() gin.HandlerFunc {
return func(c *gin.Context) {
// Generate a random base64 nonce for this request
nonce := generateNonce()
c.Set("csp_nonce", nonce)
csp := "default-src 'self'; " +
"base-uri 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"form-action 'self'; " +
"img-src 'self' data: blob:; " +
"font-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"script-src 'self' 'nonce-" + nonce + "'"
c.Writer.Header().Set("Content-Security-Policy", csp)
c.Next()
}
}
func generateNonce() string {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "" // if generation fails, return empty; policy will omit nonce
}
return base64.RawURLEncoding.EncodeToString(b)
}

View File

@@ -77,7 +77,7 @@ func handleValidationError(validationErrors validator.ValidationErrors) string {
case "email": case "email":
errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName) errorMessage = fmt.Sprintf("%s must be a valid email address", fieldName)
case "username": case "username":
errorMessage = fmt.Sprintf("%s must only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName) errorMessage = fmt.Sprintf("%s must only contain letters, numbers, underscores, dots, hyphens, and '@' symbols and not start or end with a special character", fieldName)
case "url": case "url":
errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName) errorMessage = fmt.Sprintf("%s must be a valid URL", fieldName)
case "min": case "min":

View File

@@ -74,6 +74,7 @@ type AppConfig struct {
LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"` LdapAttributeUserEmail AppConfigVariable `key:"ldapAttributeUserEmail"`
LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"` LdapAttributeUserFirstName AppConfigVariable `key:"ldapAttributeUserFirstName"`
LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"` LdapAttributeUserLastName AppConfigVariable `key:"ldapAttributeUserLastName"`
LdapAttributeUserDisplayName AppConfigVariable `key:"ldapAttributeUserDisplayName"`
LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"` LdapAttributeUserProfilePicture AppConfigVariable `key:"ldapAttributeUserProfilePicture"`
LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"` LdapAttributeGroupMember AppConfigVariable `key:"ldapAttributeGroupMember"`
LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"` LdapAttributeGroupUniqueIdentifier AppConfigVariable `key:"ldapAttributeGroupUniqueIdentifier"`

View File

@@ -4,6 +4,7 @@ import (
"database/sql/driver" "database/sql/driver"
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
@@ -21,6 +22,14 @@ type UserAuthorizedOidcClient struct {
Client OidcClient Client OidcClient
} }
func (c UserAuthorizedOidcClient) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
type OidcAuthorizationCode struct { type OidcAuthorizationCode struct {
Base Base
@@ -72,6 +81,14 @@ type OidcRefreshToken struct {
Client OidcClient Client OidcClient
} }
func (c OidcRefreshToken) Scopes() []string {
if len(c.Scope) == 0 {
return []string{}
}
return strings.Split(c.Scope, " ")
}
func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) { func (c *OidcClient) AfterFind(_ *gorm.DB) (err error) {
// Compute HasLogo field // Compute HasLogo field
c.HasLogo = c.ImageType != nil && *c.ImageType != "" c.HasLogo = c.ImageType != nil && *c.ImageType != ""

View File

@@ -13,14 +13,15 @@ import (
type User struct { type User struct {
Base Base
Username string `sortable:"true"` Username string `sortable:"true"`
Email string `sortable:"true"` Email string `sortable:"true"`
FirstName string `sortable:"true"` FirstName string `sortable:"true"`
LastName string `sortable:"true"` LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"` DisplayName string `sortable:"true"`
Locale *string IsAdmin bool `sortable:"true"`
LdapID *string Locale *string
Disabled bool `sortable:"true"` LdapID *string
Disabled bool `sortable:"true"`
CustomClaims []CustomClaim CustomClaims []CustomClaim
UserGroups []UserGroup `gorm:"many2many:user_groups_users;"` UserGroups []UserGroup `gorm:"many2many:user_groups_users;"`
@@ -31,7 +32,12 @@ func (u User) WebAuthnID() []byte { return []byte(u.ID) }
func (u User) WebAuthnName() string { return u.Username } func (u User) WebAuthnName() string { return u.Username }
func (u User) WebAuthnDisplayName() string { return u.FirstName + " " + u.LastName } func (u User) WebAuthnDisplayName() string {
if u.DisplayName != "" {
return u.DisplayName
}
return u.FirstName + " " + u.LastName
}
func (u User) WebAuthnIcon() string { return "" } func (u User) WebAuthnIcon() string { return "" }
@@ -66,7 +72,9 @@ func (u User) WebAuthnCredentialDescriptors() (descriptors []protocol.Credential
return descriptors return descriptors
} }
func (u User) FullName() string { return u.FirstName + " " + u.LastName } func (u User) FullName() string {
return u.FirstName + " " + u.LastName
}
func (u User) Initials() string { func (u User) Initials() string {
first := utils.GetFirstCharacter(u.FirstName) first := utils.GetFirstCharacter(u.FirstName)

View File

@@ -70,7 +70,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"}, SignupDefaultCustomClaims: model.AppConfigVariable{Value: "[]"},
AccentColor: model.AppConfigVariable{Value: "default"}, AccentColor: model.AppConfigVariable{Value: "default"},
// Internal // Internal
BackgroundImageType: model.AppConfigVariable{Value: "jpg"}, BackgroundImageType: model.AppConfigVariable{Value: "webp"},
LogoLightImageType: model.AppConfigVariable{Value: "svg"}, LogoLightImageType: model.AppConfigVariable{Value: "svg"},
LogoDarkImageType: model.AppConfigVariable{Value: "svg"}, LogoDarkImageType: model.AppConfigVariable{Value: "svg"},
InstanceID: model.AppConfigVariable{Value: ""}, InstanceID: model.AppConfigVariable{Value: ""},
@@ -100,6 +100,7 @@ func (s *AppConfigService) getDefaultDbConfig() *model.AppConfig {
LdapAttributeUserEmail: model.AppConfigVariable{}, LdapAttributeUserEmail: model.AppConfigVariable{},
LdapAttributeUserFirstName: model.AppConfigVariable{}, LdapAttributeUserFirstName: model.AppConfigVariable{},
LdapAttributeUserLastName: model.AppConfigVariable{}, LdapAttributeUserLastName: model.AppConfigVariable{},
LdapAttributeUserDisplayName: model.AppConfigVariable{Value: "cn"},
LdapAttributeUserProfilePicture: model.AppConfigVariable{}, LdapAttributeUserProfilePicture: model.AppConfigVariable{},
LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"}, LdapAttributeGroupMember: model.AppConfigVariable{Value: "member"},
LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{}, LdapAttributeGroupUniqueIdentifier: model.AppConfigVariable{},

View File

@@ -25,6 +25,7 @@ func isReservedClaim(key string) bool {
"name", "name",
"email", "email",
"preferred_username", "preferred_username",
"display_name",
"groups", "groups",
TokenTypeClaim, TokenTypeClaim,
"sub", "sub",

View File

@@ -78,21 +78,23 @@ func (s *TestService) SeedDatabase(baseURL string) error {
Base: model.Base{ Base: model.Base{
ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e", ID: "f4b89dc2-62fb-46bf-9f5f-c34f4eafe93e",
}, },
Username: "tim", Username: "tim",
Email: "tim.cook@test.com", Email: "tim.cook@test.com",
FirstName: "Tim", FirstName: "Tim",
LastName: "Cook", LastName: "Cook",
IsAdmin: true, DisplayName: "Tim Cook",
IsAdmin: true,
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036", ID: "1cd19686-f9a6-43f4-a41f-14a0bf5b4036",
}, },
Username: "craig", Username: "craig",
Email: "craig.federighi@test.com", Email: "craig.federighi@test.com",
FirstName: "Craig", FirstName: "Craig",
LastName: "Federighi", LastName: "Federighi",
IsAdmin: false, DisplayName: "Craig Federighi",
IsAdmin: false,
}, },
} }
for _, user := range users { for _, user := range users {
@@ -343,7 +345,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "b2c3d4e5-f6g7-8901-bcde-f12345678901", ID: "dc3c9c96-714e-48eb-926e-2d7c7858e6cf",
}, },
Token: "PARTIAL567890ABC", Token: "PARTIAL567890ABC",
ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)), ExpiresAt: datatype.DateTime(time.Now().Add(7 * 24 * time.Hour)),
@@ -352,7 +354,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "c3d4e5f6-g7h8-9012-cdef-123456789012", ID: "44de1863-ffa5-4db1-9507-4887cd7a1e3f",
}, },
Token: "EXPIRED34567890B", Token: "EXPIRED34567890B",
ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired ExpiresAt: datatype.DateTime(time.Now().Add(-24 * time.Hour)), // Expired
@@ -361,7 +363,7 @@ func (s *TestService) SeedDatabase(baseURL string) error {
}, },
{ {
Base: model.Base{ Base: model.Base{
ID: "d4e5f6g7-h8i9-0123-def0-234567890123", ID: "f1b1678b-7720-4d8b-8f91-1dbff1e2d02b",
}, },
Token: "FULLYUSED567890C", Token: "FULLYUSED567890C",
ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)), ExpiresAt: datatype.DateTime(time.Now().Add(24 * time.Hour)),

View File

@@ -262,7 +262,7 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
// prepare text part // prepare text part
var textHeader = textproto.MIMEHeader{} var textHeader = textproto.MIMEHeader{}
textHeader.Add("Content-Type", "text/plain;\n charset=UTF-8") textHeader.Add("Content-Type", "text/plain; charset=UTF-8")
textHeader.Add("Content-Transfer-Encoding", "quoted-printable") textHeader.Add("Content-Transfer-Encoding", "quoted-printable")
textPart, err := mpart.CreatePart(textHeader) textPart, err := mpart.CreatePart(textHeader)
if err != nil { if err != nil {
@@ -274,18 +274,17 @@ func prepareBody[V any](srv *EmailService, template email.Template[V], data *ema
if err != nil { if err != nil {
return "", "", fmt.Errorf("execute text template: %w", err) return "", "", fmt.Errorf("execute text template: %w", err)
} }
textQp.Close()
// prepare html part
var htmlHeader = textproto.MIMEHeader{} var htmlHeader = textproto.MIMEHeader{}
htmlHeader.Add("Content-Type", "text/html;\n charset=UTF-8") htmlHeader.Add("Content-Type", "text/html; charset=UTF-8")
htmlHeader.Add("Content-Transfer-Encoding", "quoted-printable") htmlHeader.Add("Content-Transfer-Encoding", "8bit")
htmlPart, err := mpart.CreatePart(htmlHeader) htmlPart, err := mpart.CreatePart(htmlHeader)
if err != nil { if err != nil {
return "", "", fmt.Errorf("create html part: %w", err) return "", "", fmt.Errorf("create html part: %w", err)
} }
htmlQp := quotedprintable.NewWriter(htmlPart) err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlPart, "root", data)
err = email.GetTemplate(srv.htmlTemplates, template).ExecuteTemplate(htmlQp, "root", data)
if err != nil { if err != nil {
return "", "", fmt.Errorf("execute html template: %w", err) return "", "", fmt.Errorf("execute html template: %w", err)
} }

View File

@@ -179,10 +179,12 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
} }
} }
username = norm.NFC.String(username)
var databaseUser model.User var databaseUser model.User
err = tx. err = tx.
WithContext(ctx). WithContext(ctx).
Where("username = ? AND ldap_id IS NOT NULL", norm.NFC.String(username)). Where("username = ? AND ldap_id IS NOT NULL", username).
First(&databaseUser). First(&databaseUser).
Error Error
if errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -202,6 +204,12 @@ func (s *LdapService) SyncGroups(ctx context.Context, tx *gorm.DB, client *ldap.
} }
dto.Normalize(syncGroup) dto.Normalize(syncGroup)
err = syncGroup.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user group object is not valid", slog.Any("error", err))
continue
}
if databaseGroup.ID == "" { if databaseGroup.ID == "" {
newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx) newGroup, err := s.groupService.createInternal(ctx, syncGroup, tx)
if err != nil { if err != nil {
@@ -270,6 +278,7 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
dbConfig.LdapAttributeUserFirstName.Value, dbConfig.LdapAttributeUserFirstName.Value,
dbConfig.LdapAttributeUserLastName.Value, dbConfig.LdapAttributeUserLastName.Value,
dbConfig.LdapAttributeUserProfilePicture.Value, dbConfig.LdapAttributeUserProfilePicture.Value,
dbConfig.LdapAttributeUserDisplayName.Value,
} }
// Filters must start and finish with ()! // Filters must start and finish with ()!
@@ -338,15 +347,22 @@ func (s *LdapService) SyncUsers(ctx context.Context, tx *gorm.DB, client *ldap.C
} }
newUser := dto.UserCreateDto{ newUser := dto.UserCreateDto{
Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value), Username: value.GetAttributeValue(dbConfig.LdapAttributeUserUsername.Value),
Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value), Email: value.GetAttributeValue(dbConfig.LdapAttributeUserEmail.Value),
FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value), FirstName: value.GetAttributeValue(dbConfig.LdapAttributeUserFirstName.Value),
LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value), LastName: value.GetAttributeValue(dbConfig.LdapAttributeUserLastName.Value),
IsAdmin: isAdmin, DisplayName: value.GetAttributeValue(dbConfig.LdapAttributeUserDisplayName.Value),
LdapID: ldapId, IsAdmin: isAdmin,
LdapID: ldapId,
} }
dto.Normalize(newUser) dto.Normalize(newUser)
err = newUser.Validate()
if err != nil {
slog.WarnContext(ctx, "LDAP user object is not valid", slog.Any("error", err))
continue
}
if databaseUser.ID == "" { if databaseUser.ID == "" {
_, err = s.userService.createUserInternal(ctx, newUser, true, tx) _, err = s.userService.createUserInternal(ctx, newUser, true, tx)
if errors.Is(err, &common.AlreadyInUseError{}) { if errors.Is(err, &common.AlreadyInUseError{}) {

View File

@@ -37,9 +37,11 @@ const (
GrantTypeAuthorizationCode = "authorization_code" GrantTypeAuthorizationCode = "authorization_code"
GrantTypeRefreshToken = "refresh_token" GrantTypeRefreshToken = "refresh_token"
GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code" GrantTypeDeviceCode = "urn:ietf:params:oauth:grant-type:device_code"
GrantTypeClientCredentials = "client_credentials"
ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec ClientAssertionTypeJWTBearer = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer" //nolint:gosec
AccessTokenDuration = time.Hour
RefreshTokenDuration = 30 * 24 * time.Hour // 30 days RefreshTokenDuration = 30 * 24 * time.Hour // 30 days
DeviceCodeDuration = 15 * time.Minute DeviceCodeDuration = 15 * time.Minute
) )
@@ -247,6 +249,8 @@ func (s *OidcService) CreateTokens(ctx context.Context, input dto.OidcCreateToke
return s.createTokenFromRefreshToken(ctx, input) return s.createTokenFromRefreshToken(ctx, input)
case GrantTypeDeviceCode: case GrantTypeDeviceCode:
return s.createTokenFromDeviceCode(ctx, input) return s.createTokenFromDeviceCode(ctx, input)
case GrantTypeClientCredentials:
return s.createTokenFromClientCredentials(ctx, input)
default: default:
return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{} return CreatedTokens{}, &common.OidcGrantTypeNotSupportedError{}
} }
@@ -329,7 +333,35 @@ func (s *OidcService) createTokenFromDeviceCode(ctx context.Context, input dto.O
IdToken: idToken, IdToken: idToken,
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
ExpiresIn: time.Hour, ExpiresIn: AccessTokenDuration,
}, nil
}
func (s *OidcService) createTokenFromClientCredentials(ctx context.Context, input dto.OidcCreateTokensDto) (CreatedTokens, error) {
client, err := s.verifyClientCredentialsInternal(ctx, s.db, clientAuthCredentialsFromCreateTokensDto(&input), false)
if err != nil {
return CreatedTokens{}, err
}
// GenerateOAuthAccessToken uses user.ID as a "sub" claim. Prefix is used to take those security considerations
// into account: https://datatracker.ietf.org/doc/html/rfc9068#name-security-considerations
dummyUser := model.User{
Base: model.Base{ID: "client-" + client.ID},
}
audClaim := client.ID
if input.Resource != "" {
audClaim = input.Resource
}
accessToken, err := s.jwtService.GenerateOAuthAccessToken(dummyUser, audClaim)
if err != nil {
return CreatedTokens{}, err
}
return CreatedTokens{
AccessToken: accessToken,
ExpiresIn: AccessTokenDuration,
}, nil }, nil
} }
@@ -403,7 +435,7 @@ func (s *OidcService) createTokenFromAuthorizationCode(ctx context.Context, inpu
IdToken: idToken, IdToken: idToken,
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: refreshToken, RefreshToken: refreshToken,
ExpiresIn: time.Hour, ExpiresIn: AccessTokenDuration,
}, nil }, nil
} }
@@ -447,10 +479,9 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
). ).
First(&storedRefreshToken). First(&storedRefreshToken).
Error Error
if err != nil { if errors.Is(err, gorm.ErrRecordNotFound) {
if errors.Is(err, gorm.ErrRecordNotFound) { return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{}
return CreatedTokens{}, &common.OidcInvalidRefreshTokenError{} } else if err != nil {
}
return CreatedTokens{}, err return CreatedTokens{}, err
} }
@@ -465,6 +496,19 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{}, err return CreatedTokens{}, err
} }
// Load the profile, which we need for the ID token
userClaims, err := s.getUserClaims(ctx, &storedRefreshToken.User, storedRefreshToken.Scopes(), tx)
if err != nil {
return CreatedTokens{}, err
}
// Generate a new ID token
// There's no nonce here because we don't have one with the refresh token, but that's not required
idToken, err := s.jwtService.GenerateIDToken(userClaims, input.ClientID, "")
if err != nil {
return CreatedTokens{}, err
}
// Generate a new refresh token and invalidate the old one // Generate a new refresh token and invalidate the old one
newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx) newRefreshToken, err := s.createRefreshToken(ctx, input.ClientID, storedRefreshToken.UserID, storedRefreshToken.Scope, tx)
if err != nil { if err != nil {
@@ -488,7 +532,8 @@ func (s *OidcService) createTokenFromRefreshToken(ctx context.Context, input dto
return CreatedTokens{ return CreatedTokens{
AccessToken: accessToken, AccessToken: accessToken,
RefreshToken: newRefreshToken, RefreshToken: newRefreshToken,
ExpiresIn: time.Hour, IdToken: idToken,
ExpiresIn: AccessTokenDuration,
}, nil }, nil
} }
@@ -1379,19 +1424,22 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
query := tx. query := tx.
WithContext(ctx). WithContext(ctx).
Model(&model.OidcClient{}). Model(&model.OidcClient{}).
Preload("UserAuthorizedOidcClients", "user_id = ?", userID). Preload("UserAuthorizedOidcClients", "user_id = ?", userID)
Distinct()
// If user has no groups, only return clients with no allowed user groups // If user has no groups, only return clients with no allowed user groups
if len(userGroupIDs) == 0 { if len(userGroupIDs) == 0 {
query = query. query = query.Where(`NOT EXISTS (
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id"). SELECT 1 FROM oidc_clients_allowed_user_groups
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL") WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id)`)
} else { } else {
// Return clients with no allowed user groups OR clients where user is in allowed groups query = query.Where(`
query = query. NOT EXISTS (
Joins("LEFT JOIN oidc_clients_allowed_user_groups ON oidc_clients.id = oidc_clients_allowed_user_groups.oidc_client_id"). SELECT 1 FROM oidc_clients_allowed_user_groups
Where("oidc_clients_allowed_user_groups.oidc_client_id IS NULL OR oidc_clients_allowed_user_groups.user_group_id IN (?)", userGroupIDs) WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
) OR EXISTS (
SELECT 1 FROM oidc_clients_allowed_user_groups
WHERE oidc_clients_allowed_user_groups.oidc_client_id = oidc_clients.id
AND oidc_clients_allowed_user_groups.user_group_id IN (?))`, userGroupIDs)
} }
var clients []model.OidcClient var clients []model.OidcClient
@@ -1401,7 +1449,7 @@ func (s *OidcService) ListAccessibleOidcClients(ctx context.Context, userID stri
if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) { if sortedPaginationRequest.Sort.Column == "lastUsedAt" && utils.IsValidSortDirection(sortedPaginationRequest.Sort.Direction) {
query = query. query = query.
Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID). Joins("LEFT JOIN user_authorized_oidc_clients ON oidc_clients.id = user_authorized_oidc_clients.client_id AND user_authorized_oidc_clients.user_id = ?", userID).
Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction) Order("user_authorized_oidc_clients.last_used_at " + sortedPaginationRequest.Sort.Direction + " NULLS LAST")
} }
response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients) response, err = utils.PaginateAndSort(sortedPaginationRequest, query, &clients)
@@ -1691,7 +1739,7 @@ func (s *OidcService) extractClientIDFromAssertion(assertion string) (string, er
return sub, nil 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() {
tx.Rollback() tx.Rollback()
@@ -1716,14 +1764,7 @@ func (s *OidcService) GetClientPreview(ctx context.Context, clientID string, use
return nil, &common.OidcAccessDeniedError{} return nil, &common.OidcAccessDeniedError{}
} }
dummyAuthorizedClient := model.UserAuthorizedOidcClient{ userClaims, err := s.getUserClaims(ctx, &user, scopes, tx)
UserID: userID,
ClientID: clientID,
Scope: scopes,
User: user,
}
userClaims, err := s.getUserClaimsFromAuthorizedClient(ctx, &dummyAuthorizedClient, tx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -1776,14 +1817,10 @@ func (s *OidcService) getUserClaimsForClientInternal(ctx context.Context, userID
return nil, err return nil, err
} }
return s.getUserClaimsFromAuthorizedClient(ctx, &authorizedOidcClient, tx) return s.getUserClaims(ctx, &authorizedOidcClient.User, authorizedOidcClient.Scopes(), tx)
} }
func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, authorizedClient *model.UserAuthorizedOidcClient, tx *gorm.DB) (map[string]any, error) { func (s *OidcService) getUserClaims(ctx context.Context, user *model.User, scopes []string, tx *gorm.DB) (map[string]any, error) {
user := authorizedClient.User
scopes := strings.Split(authorizedClient.Scope, " ")
claims := make(map[string]any, 10) claims := make(map[string]any, 10)
claims["sub"] = user.ID claims["sub"] = user.ID
@@ -1801,13 +1838,6 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
} }
if slices.Contains(scopes, "profile") { if slices.Contains(scopes, "profile") {
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
// Add custom claims // Add custom claims
customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx) customClaims, err := s.customClaimService.GetCustomClaimsForUserWithUserGroups(ctx, user.ID, tx)
if err != nil { if err != nil {
@@ -1826,6 +1856,15 @@ func (s *OidcService) getUserClaimsFromAuthorizedClient(ctx context.Context, aut
claims[customClaim.Key] = customClaim.Value claims[customClaim.Key] = customClaim.Value
} }
} }
// Add profile claims
claims["given_name"] = user.FirstName
claims["family_name"] = user.LastName
claims["name"] = user.FullName()
claims["display_name"] = user.DisplayName
claims["preferred_username"] = user.Username
claims["picture"] = common.EnvConfig.AppURL + "/api/users/" + user.ID + "/profile-picture.png"
} }
if slices.Contains(scopes, "email") { if slices.Contains(scopes, "email") {

View File

@@ -18,6 +18,7 @@ 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/dto" "github.com/pocket-id/pocket-id/backend/internal/dto"
"github.com/pocket-id/pocket-id/backend/internal/model"
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
) )
@@ -148,6 +149,13 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t) privateJWKDefaults, jwkSetJSONDefaults := generateTestECDSAKey(t)
require.NoError(t, err) require.NoError(t, err)
// Create a mock config and JwtService to test complete a token creation process
mockConfig := NewTestAppConfigService(&model.AppConfig{
SessionDuration: model.AppConfigVariable{Value: "60"}, // 60 minutes
})
mockJwtService, err := NewJwtService(db, mockConfig)
require.NoError(t, err)
// Create a mock HTTP client with custom transport to return the JWKS // Create a mock HTTP client with custom transport to return the JWKS
httpClient := &http.Client{ httpClient := &http.Client{
Transport: &testutils.MockRoundTripper{ Transport: &testutils.MockRoundTripper{
@@ -162,8 +170,10 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
// Init the OidcService // Init the OidcService
s := &OidcService{ s := &OidcService{
db: db, db: db,
httpClient: httpClient, jwtService: mockJwtService,
appConfigService: mockConfig,
httpClient: httpClient,
} }
s.jwkCache, err = s.getJWKCache(t.Context()) s.jwkCache, err = s.getJWKCache(t.Context())
require.NoError(t, err) require.NoError(t, err)
@@ -384,4 +394,119 @@ func TestOidcService_verifyClientCredentialsInternal(t *testing.T) {
assert.Equal(t, federatedClient.ID, client.ID) assert.Equal(t, federatedClient.ID, client.ID)
}) })
}) })
t.Run("Complete token creation flow", func(t *testing.T) {
t.Run("Client Credentials flow", func(t *testing.T) {
t.Run("Succeeds with valid secret", func(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
}
token, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(token.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-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{confidentialClient.ID}, audience, "Audience should contain confidential client ID")
})
t.Run("Fails with invalid secret", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: "invalid-secret",
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientSecretInvalidError{})
})
t.Run("Fails without client secret for public clients", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientID: publicClient.ID,
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcMissingClientCredentialsError{})
})
t.Run("Succeeds with valid assertion", func(t *testing.T) {
// Create JWT for federated identity
token, err := jwt.NewBuilder().
Issuer(federatedClientIssuer).
Audience([]string{federatedClientAudience}).
Subject(federatedClient.ID).
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{
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("Fails with invalid assertion", func(t *testing.T) {
input := dto.OidcCreateTokensDto{
ClientAssertion: "invalid.jwt.token",
ClientAssertionType: ClientAssertionTypeJWTBearer,
}
_, err := s.createTokenFromClientCredentials(t.Context(), input)
require.Error(t, err)
require.ErrorIs(t, err, &common.OidcClientAssertionInvalidError{})
})
t.Run("Succeeds with custom resource", func(t *testing.T) {
// Generate a token
input := dto.OidcCreateTokensDto{
ClientID: confidentialClient.ID,
ClientSecret: confidentialSecret,
Resource: "https://example.com/",
}
token, err := s.createTokenFromClientCredentials(t.Context(), input)
require.NoError(t, err)
require.NotNil(t, token)
// Verify the token
claims, err := s.jwtService.VerifyOAuthAccessToken(token.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-"+confidentialClient.ID, subject, "Token subject should match confidential client ID with prefix")
audience, ok := claims.Audience()
_ = assert.True(t, ok, "Audience not found in token") &&
assert.Equal(t, []string{input.Resource}, audience, "Audience should contain the resource provided in request")
})
})
})
} }

View File

@@ -245,12 +245,13 @@ func (s *UserService) CreateUser(ctx context.Context, input dto.UserCreateDto) (
func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) { func (s *UserService) createUserInternal(ctx context.Context, input dto.UserCreateDto, isLdapSync bool, tx *gorm.DB) (model.User, error) {
user := model.User{ user := model.User{
FirstName: input.FirstName, FirstName: input.FirstName,
LastName: input.LastName, LastName: input.LastName,
Email: input.Email, DisplayName: input.DisplayName,
Username: input.Username, Email: input.Email,
IsAdmin: input.IsAdmin, Username: input.Username,
Locale: input.Locale, IsAdmin: input.IsAdmin,
Locale: input.Locale,
} }
if input.LdapID != "" { if input.LdapID != "" {
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
@@ -362,6 +363,7 @@ func (s *UserService) updateUserInternal(ctx context.Context, userID string, upd
// Full update: Allow updating all personal fields // Full update: Allow updating all personal fields
user.FirstName = updatedUser.FirstName user.FirstName = updatedUser.FirstName
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.DisplayName = updatedUser.DisplayName
user.Email = updatedUser.Email user.Email = updatedUser.Email
user.Username = updatedUser.Username user.Username = updatedUser.Username
user.Locale = updatedUser.Locale user.Locale = updatedUser.Locale
@@ -600,11 +602,12 @@ func (s *UserService) SignUpInitialAdmin(ctx context.Context, signUpData dto.Sig
} }
userToCreate := dto.UserCreateDto{ userToCreate := dto.UserCreateDto{
FirstName: signUpData.FirstName, FirstName: signUpData.FirstName,
LastName: signUpData.LastName, LastName: signUpData.LastName,
Username: signUpData.Username, DisplayName: strings.TrimSpace(signUpData.FirstName + " " + signUpData.LastName),
Email: signUpData.Email, Username: signUpData.Username,
IsAdmin: true, Email: signUpData.Email,
IsAdmin: true,
} }
user, err := s.createUserInternal(ctx, userToCreate, false, tx) user, err := s.createUserInternal(ctx, userToCreate, false, tx)
@@ -736,10 +739,11 @@ func (s *UserService) SignUp(ctx context.Context, signupData dto.SignUpDto, ipAd
} }
userToCreate := dto.UserCreateDto{ userToCreate := dto.UserCreateDto{
Username: signupData.Username, Username: signupData.Username,
Email: signupData.Email, Email: signupData.Email,
FirstName: signupData.FirstName, FirstName: signupData.FirstName,
LastName: signupData.LastName, LastName: signupData.LastName,
DisplayName: strings.TrimSpace(signupData.FirstName + " " + signupData.LastName),
} }
user, err := s.createUserInternal(ctx, userToCreate, false, tx) user, err := s.createUserInternal(ctx, userToCreate, false, tx)

View File

@@ -0,0 +1,74 @@
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strings"
"time"
"github.com/pocket-id/pocket-id/backend/internal/utils"
)
const (
versionTTL = 15 * time.Minute
versionCheckURL = "https://api.github.com/repos/pocket-id/pocket-id/releases/latest"
)
type VersionService struct {
httpClient *http.Client
cache *utils.Cache[string]
}
func NewVersionService(httpClient *http.Client) *VersionService {
return &VersionService{
httpClient: httpClient,
cache: utils.New[string](versionTTL),
}
}
func (s *VersionService) GetLatestVersion(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)
defer cancel()
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, versionCheckURL, nil)
if err != nil {
return "", fmt.Errorf("create GitHub request: %w", err)
}
resp, err := s.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("get latest tag: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("GitHub API returned status %d", resp.StatusCode)
}
var payload struct {
TagName string `json:"tag_name"`
}
if err := json.NewDecoder(resp.Body).Decode(&payload); err != nil {
return "", fmt.Errorf("decode payload: %w", err)
}
if payload.TagName == "" {
return "", fmt.Errorf("GitHub API returned empty tag name")
}
return strings.TrimPrefix(payload.TagName, "v"), nil
})
var staleErr *utils.ErrStale
if errors.As(err, &staleErr) {
slog.Warn("Failed to fetch latest version, returning stale cache", "error", staleErr.Err)
return version, nil
}
return version, err
}

View File

@@ -0,0 +1,78 @@
package utils
import (
"context"
"sync/atomic"
"time"
"golang.org/x/sync/singleflight"
)
type CacheEntry[T any] struct {
Value T
FetchedAt time.Time
}
type ErrStale struct {
Err error
}
func (e *ErrStale) Error() string { return "returned stale cache: " + e.Err.Error() }
func (e *ErrStale) Unwrap() error { return e.Err }
type Cache[T any] struct {
ttl time.Duration
entry atomic.Pointer[CacheEntry[T]]
sf singleflight.Group
}
func New[T any](ttl time.Duration) *Cache[T] {
return &Cache[T]{ttl: ttl}
}
// Get returns the cached value if it's still fresh.
func (c *Cache[T]) Get() (T, bool) {
entry := c.entry.Load()
if entry == nil {
var zero T
return zero, false
}
if time.Since(entry.FetchedAt) < c.ttl {
return entry.Value, true
}
var zero T
return zero, false
}
// GetOrFetch returns the cached value if it's still fresh, otherwise calls fetch to get a new value.
func (c *Cache[T]) GetOrFetch(ctx context.Context, fetch func(context.Context) (T, error)) (T, error) {
// If fresh, serve immediately
if v, ok := c.Get(); ok {
return v, nil
}
// Fetch with singleflight to prevent multiple concurrent fetches
vAny, err, _ := c.sf.Do("singleton", func() (any, error) {
if v2, ok := c.Get(); ok {
return v2, nil
}
val, fetchErr := fetch(ctx)
if fetchErr != nil {
return nil, fetchErr
}
c.entry.Store(&CacheEntry[T]{Value: val, FetchedAt: time.Now()})
return val, nil
})
if err == nil {
return vAny.(T), nil
}
// Fetch failed. Return stale if possible.
if e := c.entry.Load(); e != nil {
return e.Value, &ErrStale{Err: err}
}
var zero T
return zero, err
}

View File

@@ -3,8 +3,7 @@ package email
import ( import (
"fmt" "fmt"
htemplate "html/template" htemplate "html/template"
"io/fs" "path/filepath"
"path"
ttemplate "text/template" ttemplate "text/template"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
@@ -27,71 +26,35 @@ func GetTemplate[U any, V any](templateMap TemplateMap[U], template Template[V])
return templateMap[template.Path] return templateMap[template.Path]
} }
type cloneable[V pareseable[V]] interface {
Clone() (V, error)
}
type pareseable[V any] interface {
ParseFS(fs.FS, ...string) (V, error)
}
func prepareTemplate[V pareseable[V]](templateFS fs.FS, template string, rootTemplate cloneable[V], suffix string) (V, error) {
tmpl, err := rootTemplate.Clone()
if err != nil {
return *new(V), fmt.Errorf("clone root template: %w", err)
}
filename := fmt.Sprintf("%s%s", template, suffix)
templatePath := path.Join("email-templates", filename)
_, err = tmpl.ParseFS(templateFS, templatePath)
if err != nil {
return *new(V), fmt.Errorf("parsing template '%s': %w", template, err)
}
return tmpl, nil
}
func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) { func PrepareTextTemplates(templates []string) (map[string]*ttemplate.Template, error) {
components := path.Join("email-templates", "components", "*_text.tmpl")
rootTmpl, err := ttemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
textTemplates := make(map[string]*ttemplate.Template, len(templates)) textTemplates := make(map[string]*ttemplate.Template, len(templates))
for _, tmpl := range templates { for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone() filename := tmpl + "_text.tmpl"
templatePath := filepath.Join("email-templates", filename)
parsedTemplate, err := ttemplate.ParseFS(resources.FS, templatePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("clone root template: %w", err) return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
} }
textTemplates[tmpl], err = prepareTemplate[*ttemplate.Template](resources.FS, tmpl, rootTmplClone, "_text.tmpl") textTemplates[tmpl] = parsedTemplate
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
} }
return textTemplates, nil return textTemplates, nil
} }
func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) { func PrepareHTMLTemplates(templates []string) (map[string]*htemplate.Template, error) {
components := path.Join("email-templates", "components", "*_html.tmpl")
rootTmpl, err := htemplate.ParseFS(resources.FS, components)
if err != nil {
return nil, fmt.Errorf("unable to parse templates '%s': %w", components, err)
}
htmlTemplates := make(map[string]*htemplate.Template, len(templates)) htmlTemplates := make(map[string]*htemplate.Template, len(templates))
for _, tmpl := range templates { for _, tmpl := range templates {
rootTmplClone, err := rootTmpl.Clone() filename := tmpl + "_html.tmpl"
templatePath := filepath.Join("email-templates", filename)
parsedTemplate, err := htemplate.ParseFS(resources.FS, templatePath)
if err != nil { if err != nil {
return nil, fmt.Errorf("clone root template: %w", err) return nil, fmt.Errorf("parsing template '%s': %w", tmpl, err)
} }
htmlTemplates[tmpl], err = prepareTemplate[*htemplate.Template](resources.FS, tmpl, rootTmplClone, "_html.tmpl") htmlTemplates[tmpl] = parsedTemplate
if err != nil {
return nil, fmt.Errorf("parse '%s': %w", tmpl, err)
}
} }
return htmlTemplates, nil return htmlTemplates, nil

View File

@@ -1,12 +1,16 @@
package utils package utils
import ( import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"mime/multipart" "mime/multipart"
"os" "os"
"path/filepath" "path/filepath"
"syscall"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/pocket-id/pocket-id/backend/resources" "github.com/pocket-id/pocket-id/backend/resources"
@@ -32,6 +36,12 @@ func GetImageMimeType(ext string) string {
return "image/x-icon" return "image/x-icon"
case "gif": case "gif":
return "image/gif" return "image/gif"
case "webp":
return "image/webp"
case "avif":
return "image/avif"
case "heic":
return "image/heic"
default: default:
return "" return ""
} }
@@ -40,29 +50,45 @@ func GetImageMimeType(ext string) string {
func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error { func CopyEmbeddedFileToDisk(srcFilePath, destFilePath string) error {
srcFile, err := resources.FS.Open(srcFilePath) srcFile, err := resources.FS.Open(srcFilePath)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to open embedded file: %w", err)
} }
defer srcFile.Close() defer srcFile.Close()
err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm) err = os.MkdirAll(filepath.Dir(destFilePath), os.ModePerm)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to create destination directory: %w", err)
} }
destFile, err := os.Create(destFilePath) destFile, err := os.Create(destFilePath)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to open destination file: %w", err)
} }
defer destFile.Close() defer destFile.Close()
_, err = io.Copy(destFile, srcFile) _, err = io.Copy(destFile, srcFile)
if err != nil { if err != nil {
return err return fmt.Errorf("failed to write to destination file: %w", err)
} }
return nil return nil
} }
func EmbeddedFileSha256(filePath string) ([]byte, error) {
f, err := resources.FS.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open embedded file: %w", err)
}
defer f.Close()
h := sha256.New()
_, err = io.Copy(h, f)
if err != nil {
return nil, fmt.Errorf("failed to read embedded file: %w", err)
}
return h.Sum(nil), nil
}
func SaveFile(file *multipart.FileHeader, dst string) error { func SaveFile(file *multipart.FileHeader, dst string) error {
src, err := file.Open() src, err := file.Open()
if err != nil { if err != nil {
@@ -136,3 +162,41 @@ func FileExists(path string) (bool, error) {
} }
return !s.IsDir(), nil return !s.IsDir(), nil
} }
// IsWritableDir checks if a directory exists and is writable
func IsWritableDir(dir string) (bool, error) {
// Check if directory exists and it's actually a directory
info, err := os.Stat(dir)
if os.IsNotExist(err) {
return false, nil
} else if err != nil {
return false, fmt.Errorf("failed to stat '%s': %w", dir, err)
}
if !info.IsDir() {
return false, nil
}
// Generate a random suffix for the test file to avoid conflicts
randomBytes := make([]byte, 8)
_, err = io.ReadFull(rand.Reader, randomBytes)
if err != nil {
return false, fmt.Errorf("failed to generate random bytes: %w", err)
}
// Check if directory is writable by trying to create a temporary file
testFile := filepath.Join(dir, ".pocketid_test_write_"+hex.EncodeToString(randomBytes))
defer os.Remove(testFile)
file, err := os.Create(testFile)
if err != nil {
if os.IsPermission(err) || errors.Is(err, syscall.EROFS) {
return false, nil
}
return false, fmt.Errorf("failed to create test file: %w", err)
}
_ = file.Close()
return true, nil
}

View File

@@ -3,9 +3,28 @@ package utils
import ( import (
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"fmt"
"io"
"os"
) )
func CreateSha256Hash(input string) string { func CreateSha256Hash(input string) string {
hash := sha256.Sum256([]byte(input)) hash := sha256.Sum256([]byte(input))
return hex.EncodeToString(hash[:]) return hex.EncodeToString(hash[:])
} }
func CreateSha256FileHash(filePath string) ([]byte, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, fmt.Errorf("failed to open file: %w", err)
}
defer f.Close()
h := sha256.New()
_, err = io.Copy(h, f)
if err != nil {
return nil, fmt.Errorf("failed to read file: %w", err)
}
return h.Sum(nil), nil
}

View File

@@ -9,6 +9,7 @@ import (
"github.com/lestrrat-go/jwx/v3/jwk" "github.com/lestrrat-go/jwx/v3/jwk"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/pocket-id/pocket-id/backend/internal/model" "github.com/pocket-id/pocket-id/backend/internal/model"
cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto" cryptoutils "github.com/pocket-id/pocket-id/backend/internal/utils/crypto"
@@ -95,7 +96,14 @@ func (f *KeyProviderDatabase) SaveKey(key jwk.Key) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
err = f.db.WithContext(ctx).Create(&row).Error err = f.db.
WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "key"}},
DoUpdates: clause.AssignmentColumns([]string{"value"}),
}).
Create(&row).
Error
if err != nil { if err != nil {
// There's one scenario where if Pocket ID is started fresh with more than 1 replica, they both could be trying to create the private key in the database at the same time // There's one scenario where if Pocket ID is started fresh with more than 1 replica, they both could be trying to create the private key in the database at the same time
// In this case, only one of the replicas will succeed; the other one(s) will return an error here, which will cascade down and cause the replica(s) to crash and be restarted (at that point they'll load the then-existing key from the database) // In this case, only one of the replicas will succeed; the other one(s) will return an error here, which will cascade down and cause the replica(s) to crash and be restarted (at that point they'll load the then-existing key from the database)

View File

@@ -3,3 +3,11 @@ package utils
func Ptr[T any](v T) *T { func Ptr[T any](v T) *T {
return &v return &v
} }
func PtrValueOrZero[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}

View File

@@ -55,7 +55,9 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
// Perform migrations with the embedded migrations // Perform migrations with the embedded migrations
sqlDB, err := db.DB() sqlDB, err := db.DB()
require.NoError(t, err, "Failed to get sql.DB") require.NoError(t, err, "Failed to get sql.DB")
driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{}) driver, err := sqliteMigrate.WithInstance(sqlDB, &sqliteMigrate.Config{
NoTxWrap: true,
})
require.NoError(t, err, "Failed to create migration driver") require.NoError(t, err, "Failed to create migration driver")
source, err := iofs.New(resources.FS, "migrations/sqlite") source, err := iofs.New(resources.FS, "migrations/sqlite")
require.NoError(t, err, "Failed to create embedded migration source") require.NoError(t, err, "Failed to create embedded migration source")
@@ -63,6 +65,8 @@ func NewDatabaseForTest(t *testing.T) *gorm.DB {
require.NoError(t, err, "Failed to create migration instance") require.NoError(t, err, "Failed to create migration instance")
err = m.Up() err = m.Up()
require.NoError(t, err, "Failed to perform migrations") require.NoError(t, err, "Failed to perform migrations")
_, err = sqlDB.Exec("PRAGMA foreign_keys = OFF;")
require.NoError(t, err, "Failed to disable foreign keys")
return db return db
} }

File diff suppressed because one or more lines are too long

View File

@@ -1,17 +1,3 @@
{{ define "base" }} {{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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" 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">
<div class="logo"> <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><!--7--><!--/$--></body></html>{{end}}
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/>
<h1>{{ .AppName }}</h1>
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>API Key Expiring Soon</h2>
<p>
Hello {{ .Data.Name }},<br/><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>.<br/><br/>
Please generate a new API key if you need continued access.
</p>
</div>
{{ end }}

View File

@@ -1,10 +1,12 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
API Key Expiring Soon
====================
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" }}. API KEY EXPIRING SOON
Please generate a new API key if you need continued access. Warning
{{ end -}}
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"}}.
Please generate a new API key if you need continued access.{{end}}

View File

@@ -1,14 +0,0 @@
{{ define "root" }}
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
{{ template "style" . }}
</head>
<body>
<div class="container">
{{ template "base" . }}
</div>
</body>
</html>
{{ end }}

View File

@@ -1,7 +0,0 @@
{{- define "root" -}}
{{- template "base" . -}}
{{- end }}
--
This is automatically sent email from {{.AppName}}.

View File

@@ -1,92 +0,0 @@
{{ define "style" }}
<style>
/* Reset styles for email clients */
body, table, td, p, a {
margin: 0;
padding: 0;
border: 0;
font-size: 100%;
font-family: Arial, sans-serif;
line-height: 1.5;
}
body {
background-color: #f0f0f0;
color: #333;
}
.container {
width: 100%;
max-width: 600px;
margin: 40px auto;
background-color: #fff;
border-radius: 10px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
padding: 32px;
}
.header {
display: flex;
margin-bottom: 24px;
}
.header .logo img {
width: 32px;
height: 32px;
vertical-align: middle;
}
.header h1 {
font-size: 1.5rem;
font-weight: bold;
display: inline-block;
vertical-align: middle;
margin-left: 8px;
}
.warning {
background-color: #ffd966;
color: #7f6000;
padding: 4px 12px;
border-radius: 50px;
font-size: 0.875rem;
margin: auto 0 auto auto;
}
.content {
background-color: #fafafa;
padding: 24px;
border-radius: 10px;
}
.content h2 {
font-size: 1.25rem;
font-weight: bold;
margin-bottom: 16px;
}
.grid {
width: 100%;
margin-bottom: 16px;
}
.grid td {
width: 50%;
padding-bottom: 8px;
vertical-align: top;
}
.label {
color: #888;
font-size: 0.875rem;
}
.message {
font-size: 1rem;
line-height: 1.5;
margin-top: 16px;
}
.button {
background-color: #000000;
color: #ffffff;
padding: 0.7rem 1.5rem;
text-decoration: none;
border-radius: 4px;
font-size: 1rem;
font-weight: 500;
display: inline-block;
margin-top: 24px;
}
.button-container {
text-align: center;
}
</style>
{{ end }}

View File

@@ -1,40 +1,5 @@
{{ define "base" }} {{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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" 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">New Sign-In Detected</h1></td><td align="right" data-id="__react-email-column">
<div class="logo"> <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">Your <!-- -->{{.AppName}}<!-- --> account was recently accessed from a new IP address or browser. If you recognize this activity, no further action is required.</p><h4 style="font-size:1rem;font-weight:bold;margin:30px 0 10px 0">Details</h4><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" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Approximate Location</p>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/> <p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.City}}<!-- -->, <!-- -->{{.Data.Country}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">IP Address</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.IPAddress}}</p></td></tr></tbody></table><table align="center" width="100%" border="0" cellPadding="0" cellSpacing="0" role="presentation" style="margin-top:10px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Device</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">
<h1>{{ .AppName }}</h1> {{.Data.Device}}</p></td><td data-id="__react-email-column" style="width:225px"><p style="font-size:12px;line-height:24px;margin:0;color:gray;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">Sign-In Time</p><p style="font-size:14px;line-height:24px;margin:0;margin-top:0;margin-bottom:0;margin-left:0;margin-right:0">{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}</p></td></tr></tbody></table></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
</div>
<div class="warning">Warning</div>
</div>
<div class="content">
<h2>New Sign-In Detected</h2>
<table class="grid">
<tr>
{{ if and .Data.City .Data.Country }}
<td>
<p class="label">Approximate Location</p>
<p>{{ .Data.City }}, {{ .Data.Country }}</p>
</td>
{{ end }}
<td>
<p class="label">IP Address</p>
<p>{{ .Data.IPAddress }}</p>
</td>
</tr>
<tr>
<td>
<p class="label">Device</p>
<p>{{ .Data.Device }}</p>
</td>
<td>
<p class="label">Sign-In Time</p>
<p>{{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC" }}</p>
</td>
</tr>
</table>
<p class="message">
This sign-in was detected from a new device or location. If you recognize this activity, you can
safely ignore this message. If not, please review your account and security settings.
</p>
</div>
{{ end -}}

View File

@@ -1,15 +1,27 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
New Sign-In Detected
====================
{{ if and .Data.City .Data.Country }}
Approximate Location: {{ .Data.City }}, {{ .Data.Country }}
{{ end }}
IP Address: {{ .Data.IPAddress }}
Device: {{ .Data.Device }}
Time: {{ .Data.DateTime.Format "2006-01-02 15:04:05 UTC"}}
This sign-in was detected from a new device or location. If you recognize NEW SIGN-IN DETECTED
this activity, you can safely ignore this message. If not, please review
your account and security settings. Warning
{{ end -}}
Your {{.AppName}} account was recently accessed from a new IP address or
browser. If you recognize this activity, no further action is required.
DETAILS
Approximate Location
{{.Data.City}}, {{.Data.Country}}
IP Address
{{.Data.IPAddress}}
Device
{{.Data.Device}}
Sign-In Time
{{.Data.DateTime.Format "January 2, 2006 at 3:04 PM MST"}}{{end}}

View File

@@ -1,17 +1,4 @@
{{ define "base" }} {{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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" 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">Your Login Code</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
<div class="logo"> Click the button below to sign in to <!-- -->{{.AppName}}<!-- --> with a login code.<br/>Or visit<!-- --> <a href="{{.Data.LoginLink}}" style="color:#000;text-decoration-line:none;text-decoration:underline;font-family:Arial, sans-serif" target="_blank">{{.Data.LoginLink}}</a> <!-- -->and enter the code <strong>{{.Data.Code}}</strong>.<br/><br/>This code expires in <!-- -->{{.Data.ExpirationString}}<!-- -->.</p><div style="text-align:center"><a href="{{.Data.LoginLinkWithCode}}" style="line-height:100%;text-decoration:none;display:inline-block;max-width:100%;mso-padding-alt:0px;background-color:#000000;color:#ffffff;padding:12px 24px;border-radius:4px;font-size:15px;font-weight:500;cursor:pointer;margin-top:10px;padding-top:12px;padding-right:24px;padding-bottom:12px;padding-left:24px" target="_blank"><span><!--[if mso]><i style="mso-font-width:400%;mso-text-raise:18" hidden>&#8202;&#8202;&#8202;</i><![endif]--></span>
<img src="{{ .LogoURL }}" alt="{{ .AppName }}" width="32" height="32" style="width: 32px; height: 32px; max-width: 32px;"/> <span style="max-width:100%;display:inline-block;line-height:120%;mso-padding-alt:0px;mso-text-raise:9px">Sign In</span><span><!--[if mso]><i style="mso-font-width:400%" hidden>&#8202;&#8202;&#8202;&#8203;</i><![endif]--></span></a></div></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<h2>Login Code</h2>
<p class="message">
Click the button below to sign in to {{ .AppName }} with a login code.</br>Or visit <a href="{{ .Data.LoginLink }}">{{ .Data.LoginLink }}</a> and enter the code <strong>{{ .Data.Code }}</strong>.</br></br>This code expires in {{.Data.ExpirationString}}.
</p>
<div class="button-container">
<a class="button" href="{{ .Data.LoginLinkWithCode }}" class="button">Sign In</a>
</div>
</div>
{{ end -}}

View File

@@ -1,10 +1,12 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
Login Code
====================
Click the link below to sign in to {{ .AppName }} with a login code. This code expires in {{.Data.ExpirationString}}.
{{ .Data.LoginLinkWithCode }} YOUR LOGIN CODE
Or visit {{ .Data.LoginLink }} and enter the the code "{{ .Data.Code }}". Click the button below to sign in to {{.AppName}} with a login code.
{{ end -}} Or visit {{.Data.LoginLink}} {{.Data.LoginLink}} and enter the code
{{.Data.Code}}.
This code expires in {{.Data.ExpirationString}}.
Sign In {{.Data.LoginLinkWithCode}}{{end}}

View File

@@ -1,11 +1,3 @@
{{ define "base" -}} {{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="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="width:210px;margin-bottom:16px"><tbody style="width:100%"><tr style="width:100%"><td data-id="__react-email-column">
<div class="header"> <img alt="{{.AppName}}" height="32" src="{{.LogoURL}}" style="display:block;outline:none;border:none;text-decoration:none;width:32px;height:32px;vertical-align:middle;margin-right:8px" 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">Test Email</h1></td><td align="right" data-id="__react-email-column"></td></tr></tbody></table><p style="font-size:14px;line-height:24px;margin-top:16px;margin-bottom:16px">
<div class="logo"> Your email setup is working correctly!</p></div></td></tr></tbody></table><!--7--><!--/$--></body></html>{{end}}
<img src="{{ .LogoURL }}" alt="{{ .AppName }}"/>
<h1>{{ .AppName }}</h1>
</div>
</div>
<div class="content">
<p>This is a test email.</p>
</div>
{{ end -}}

View File

@@ -1,3 +1,6 @@
{{ define "base" -}} {{define "root"}}{{.AppName}}
This is a test email.
{{ end -}}
TEST EMAIL
Your email setup is working correctly!{{end}}

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project // Embedded file systems for the project
//go:embed email-templates images migrations fonts aaguids.json //go:embed email-templates/*.tmpl images migrations fonts aaguids.json
var FS embed.FS var FS embed.FS

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 291 KiB

View File

@@ -1 +1,7 @@
-- No-op ALTER TABLE public.audit_logs
DROP CONSTRAINT IF EXISTS audit_logs_user_id_fkey,
ADD CONSTRAINT audit_logs_user_id_fkey
FOREIGN KEY (user_id) REFERENCES public.users (id);
ALTER TABLE public.oidc_authorization_codes
DROP CONSTRAINT IF EXISTS oidc_authorization_codes_client_fk;

View File

@@ -0,0 +1 @@
-- No-op because strings can't be converted to UUIDs

View File

@@ -0,0 +1,57 @@
-- Drop foreign keys that reference oidc_clients(id)
ALTER TABLE oidc_authorization_codes
DROP CONSTRAINT IF EXISTS oidc_authorization_codes_client_fk;
ALTER TABLE user_authorized_oidc_clients
DROP CONSTRAINT IF EXISTS user_authorized_oidc_clients_client_id_fkey;
ALTER TABLE oidc_refresh_tokens
DROP CONSTRAINT IF EXISTS oidc_refresh_tokens_client_id_fkey;
ALTER TABLE oidc_device_codes
DROP CONSTRAINT IF EXISTS oidc_device_codes_client_id_fkey;
ALTER TABLE oidc_clients_allowed_user_groups
DROP CONSTRAINT IF EXISTS oidc_clients_allowed_user_groups_oidc_client_id_fkey;
-- Alter child columns to TEXT
ALTER TABLE oidc_authorization_codes
ALTER COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE user_authorized_oidc_clients
ALTER
COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE oidc_refresh_tokens
ALTER
COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE oidc_device_codes
ALTER
COLUMN client_id TYPE TEXT USING client_id::text;
ALTER TABLE oidc_clients_allowed_user_groups
ALTER
COLUMN oidc_client_id TYPE TEXT USING oidc_client_id::text;
-- Alter parent primary key column to TEXT
ALTER TABLE oidc_clients
ALTER
COLUMN id TYPE TEXT USING id::text;
-- Recreate foreign keys with the new type
ALTER TABLE oidc_authorization_codes
ADD CONSTRAINT oidc_authorization_codes_client_fk
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE user_authorized_oidc_clients
ADD CONSTRAINT user_authorized_oidc_clients_client_id_fkey
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE oidc_refresh_tokens
ADD CONSTRAINT oidc_refresh_tokens_client_id_fkey
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE oidc_device_codes
ADD CONSTRAINT oidc_device_codes_client_id_fkey
FOREIGN KEY (client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;
ALTER TABLE oidc_clients_allowed_user_groups
ADD CONSTRAINT oidc_clients_allowed_user_groups_oidc_client_id_fkey
FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE;

View File

@@ -0,0 +1,3 @@
ALTER TABLE users DROP COLUMN display_name;
ALTER TABLE users ALTER COLUMN username TYPE TEXT;

View File

@@ -0,0 +1,6 @@
ALTER TABLE users ADD COLUMN display_name TEXT;
UPDATE users SET display_name = trim(coalesce(first_name,'') || ' ' || coalesce(last_name,''));
ALTER TABLE users ALTER COLUMN display_name SET NOT NULL;
CREATE EXTENSION IF NOT EXISTS citext;
ALTER TABLE users ALTER COLUMN username TYPE CITEXT COLLATE "C";

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE users CREATE TABLE users
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
@@ -78,3 +80,5 @@ CREATE TABLE application_configuration_variables
is_public NUMERIC DEFAULT FALSE NOT NULL, is_public NUMERIC DEFAULT FALSE NOT NULL,
is_internal NUMERIC DEFAULT FALSE NOT NULL is_internal NUMERIC DEFAULT FALSE NOT NULL
); );
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE webauthn_credentials ADD COLUMN backup_eligible BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE webauthn_credentials ADD COLUMN backup_eligible BOOLEAN NOT NULL DEFAULT FALSE;
ALTER TABLE webauthn_credentials ADD COLUMN backup_state BOOLEAN NOT NULL DEFAULT FALSE; ALTER TABLE webauthn_credentials ADD COLUMN backup_state BOOLEAN NOT NULL DEFAULT FALSE;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE webauthn_credentials DROP COLUMN backup_eligible; ALTER TABLE webauthn_credentials DROP COLUMN backup_eligible;
ALTER TABLE webauthn_credentials DROP COLUMN backup_state; ALTER TABLE webauthn_credentials DROP COLUMN backup_state;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE app_config_variables ALTER TABLE app_config_variables
RENAME TO application_configuration_variables; RENAME TO application_configuration_variables;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE application_configuration_variables ALTER TABLE application_configuration_variables
RENAME TO app_config_variables; RENAME TO app_config_variables;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
create table oidc_clients create table oidc_clients
( (
id TEXT not null primary key, id TEXT not null primary key,
@@ -21,3 +23,5 @@ select id,
from oidc_clients_dg_tmp; from oidc_clients_dg_tmp;
drop table oidc_clients_dg_tmp; drop table oidc_clients_dg_tmp;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
create table oidc_clients_dg_tmp create table oidc_clients_dg_tmp
( (
id TEXT not null primary key, id TEXT not null primary key,
@@ -24,3 +26,5 @@ drop table oidc_clients;
alter table oidc_clients_dg_tmp alter table oidc_clients_dg_tmp
rename to oidc_clients; rename to oidc_clients;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE audit_logs; DROP TABLE audit_logs;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE audit_logs CREATE TABLE audit_logs
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
@@ -8,3 +10,5 @@ CREATE TABLE audit_logs
data BLOB NOT NULL, data BLOB NOT NULL,
user_id TEXT REFERENCES users user_id TEXT REFERENCES users
); );
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE user_groups; DROP TABLE user_groups;
DROP TABLE user_groups_users; DROP TABLE user_groups_users;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE user_groups CREATE TABLE user_groups
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
@@ -14,3 +16,5 @@ CREATE TABLE user_groups_users
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE, FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
); );
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE audit_logs DROP COLUMN country; ALTER TABLE audit_logs DROP COLUMN country;
ALTER TABLE audit_logs DROP COLUMN city; ALTER TABLE audit_logs DROP COLUMN city;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE audit_logs ADD COLUMN country TEXT; ALTER TABLE audit_logs ADD COLUMN country TEXT;
ALTER TABLE audit_logs ADD COLUMN city TEXT; ALTER TABLE audit_logs ADD COLUMN city TEXT;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Convert the Unix timestamps back to DATETIME format -- Convert the Unix timestamps back to DATETIME format
UPDATE user_groups UPDATE user_groups
@@ -26,3 +28,5 @@ SET created_at = datetime(created_at, 'unixepoch');
UPDATE webauthn_sessions UPDATE webauthn_sessions
SET created_at = datetime(created_at, 'unixepoch'), SET created_at = datetime(created_at, 'unixepoch'),
expires_at = datetime(expires_at, 'unixepoch'); expires_at = datetime(expires_at, 'unixepoch');
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
-- Convert the DATETIME fields to Unix timestamps (in seconds) -- Convert the DATETIME fields to Unix timestamps (in seconds)
UPDATE user_groups UPDATE user_groups
SET created_at = strftime('%s', created_at); SET created_at = strftime('%s', created_at);
@@ -25,3 +27,5 @@ SET created_at = strftime('%s', created_at);
UPDATE webauthn_sessions UPDATE webauthn_sessions
SET created_at = strftime('%s', created_at), SET created_at = strftime('%s', created_at),
expires_at = strftime('%s', expires_at); expires_at = strftime('%s', expires_at);
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE app_config_variables DROP COLUMN default_value; ALTER TABLE app_config_variables DROP COLUMN default_value;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE app_config_variables ADD COLUMN default_value TEXT; ALTER TABLE app_config_variables ADD COLUMN default_value TEXT;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE custom_claims; DROP TABLE custom_claims;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE custom_claims CREATE TABLE custom_claims
( (
id TEXT NOT NULL PRIMARY KEY, id TEXT NOT NULL PRIMARY KEY,
@@ -13,3 +15,5 @@ CREATE TABLE custom_claims
CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id), CONSTRAINT custom_claims_unique UNIQUE (key, user_id, user_group_id),
CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL) CHECK (user_id IS NOT NULL OR user_group_id IS NOT NULL)
); );
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge; ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge;
ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256; ALTER TABLE oidc_authorization_codes DROP COLUMN code_challenge_method_sha256;
ALTER TABLE oidc_clients DROP COLUMN is_public; ALTER TABLE oidc_clients DROP COLUMN is_public;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,7 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT; ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge TEXT;
ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC; ALTER TABLE oidc_authorization_codes ADD COLUMN code_challenge_method_sha256 NUMERIC;
ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE; ALTER TABLE oidc_clients ADD COLUMN is_public BOOLEAN DEFAULT FALSE;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients DROP COLUMN pkce_enabled; ALTER TABLE oidc_clients DROP COLUMN pkce_enabled;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE; ALTER TABLE oidc_clients ADD COLUMN pkce_enabled BOOLEAN DEFAULT FALSE;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE users DROP COLUMN ldap_id; ALTER TABLE users DROP COLUMN ldap_id;
ALTER TABLE user_groups DROP COLUMN ldap_id; ALTER TABLE user_groups DROP COLUMN ldap_id;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,5 +1,9 @@
PRAGMA foreign_keys=OFF;
BEGIN;
ALTER TABLE users ADD COLUMN ldap_id TEXT; ALTER TABLE users ADD COLUMN ldap_id TEXT;
ALTER TABLE user_groups ADD COLUMN ldap_id TEXT; ALTER TABLE user_groups ADD COLUMN ldap_id TEXT;
CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id); CREATE UNIQUE INDEX users_ldap_id ON users (ldap_id);
CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id); CREATE UNIQUE INDEX user_groups_ldap_id ON user_groups (ldap_id);
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled'; UPDATE app_config_variables SET key = 'emailEnabled' WHERE key = 'emailLoginNotificationEnabled';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled'; UPDATE app_config_variables SET key = 'emailLoginNotificationEnabled' WHERE key = 'emailEnabled';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
UPDATE users SET ldap_id = '' WHERE ldap_id IS NULL; UPDATE users SET ldap_id = '' WHERE ldap_id IS NULL;
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL; UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,2 +1,6 @@
PRAGMA foreign_keys=OFF;
BEGIN;
UPDATE users SET ldap_id = null WHERE ldap_id = ''; UPDATE users SET ldap_id = null WHERE ldap_id = '';
UPDATE user_groups SET ldap_id = null WHERE ldap_id = ''; UPDATE user_groups SET ldap_id = null WHERE ldap_id = '';
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
DROP TABLE oidc_clients_allowed_user_groups; DROP TABLE oidc_clients_allowed_user_groups;
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1,3 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
CREATE TABLE oidc_clients_allowed_user_groups CREATE TABLE oidc_clients_allowed_user_groups
( (
user_group_id TEXT NOT NULL, user_group_id TEXT NOT NULL,
@@ -6,3 +8,5 @@ CREATE TABLE oidc_clients_allowed_user_groups
FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE, FOREIGN KEY (oidc_client_id) REFERENCES oidc_clients (id) ON DELETE CASCADE,
FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE FOREIGN KEY (user_group_id) REFERENCES user_groups (id) ON DELETE CASCADE
); );
COMMIT;
PRAGMA foreign_keys=ON;

View File

@@ -1 +1,5 @@
PRAGMA foreign_keys=OFF;
BEGIN;
UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL; UPDATE user_groups SET ldap_id = '' WHERE ldap_id IS NULL;
COMMIT;
PRAGMA foreign_keys=ON;

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