mirror of
https://github.com/pocket-id/pocket-id.git
synced 2026-07-20 19:51:27 +02:00
Merge branch 'main' into invite-email-domain
This commit is contained in:
6
.github/workflows/build-next.yml
vendored
6
.github/workflows/build-next.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -82,11 +82,11 @@ jobs:
|
||||
MACOS_NOTARY_ISSUER_ID: ${{ secrets.MACOS_NOTARY_ISSUER_ID }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
2
.github/workflows/e2e-tests.yml
vendored
2
.github/workflows/e2e-tests.yml
vendored
@@ -45,7 +45,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
@@ -76,12 +76,12 @@ jobs:
|
||||
DISCORD_WEBHOOK_TOKEN: ${{ secrets.DISCORD_WEBHOOK_TOKEN }}
|
||||
|
||||
- name: Binary attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/checksums.txt
|
||||
|
||||
- name: Container image attestation
|
||||
uses: actions/attest@v4
|
||||
uses: actions/attest@v4.1.1
|
||||
with:
|
||||
subject-checksums: ./dist/digests.txt
|
||||
|
||||
|
||||
2
.github/workflows/svelte-check.yml
vendored
2
.github/workflows/svelte-check.yml
vendored
@@ -42,7 +42,7 @@ jobs:
|
||||
uses: pnpm/action-setup@v5
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -4,9 +4,9 @@ go 1.26.5
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0
|
||||
github.com/aws/smithy-go v1.27.3
|
||||
github.com/caarlos0/env/v11 v11.4.1
|
||||
github.com/cenkalti/backoff/v5 v5.0.3
|
||||
@@ -17,7 +17,7 @@ require (
|
||||
github.com/fsnotify/fsnotify v1.10.1
|
||||
github.com/gin-contrib/slog v1.2.1
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-co-op/gocron/v2 v2.21.2
|
||||
github.com/go-co-op/gocron/v2 v2.22.0
|
||||
github.com/go-jose/go-jose/v4 v4.1.4
|
||||
github.com/go-ldap/ldap/v3 v3.4.13
|
||||
github.com/go-playground/validator/v10 v10.30.3
|
||||
@@ -32,13 +32,14 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lestrrat-go/httprc/v3 v3.0.6
|
||||
github.com/lestrrat-go/jwx/v3 v3.1.1
|
||||
github.com/libtnb/sqlite v1.1.2
|
||||
github.com/libtnb/sqlite v1.2.0
|
||||
github.com/lmittmann/tint v1.1.3
|
||||
github.com/mattn/go-isatty v0.0.22
|
||||
github.com/mileusna/useragent v1.3.5
|
||||
github.com/orandin/slog-gorm v1.4.0
|
||||
github.com/ory/fosite v0.49.1-0.20250703093431-a5f0b09bf31c
|
||||
github.com/oschwald/maxminddb-golang/v2 v2.4.1
|
||||
github.com/pires/go-proxyproto v0.15.0
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/zitadel/exifremove v0.1.0
|
||||
@@ -52,10 +53,10 @@ require (
|
||||
go.opentelemetry.io/otel/sdk/log v0.20.0
|
||||
go.opentelemetry.io/otel/sdk/metric v1.44.0
|
||||
go.opentelemetry.io/otel/trace v1.44.0
|
||||
golang.org/x/crypto v0.53.0
|
||||
golang.org/x/image v0.43.0
|
||||
golang.org/x/crypto v0.54.0
|
||||
golang.org/x/image v0.44.0
|
||||
golang.org/x/sync v0.22.0
|
||||
golang.org/x/text v0.39.0
|
||||
golang.org/x/text v0.40.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.2
|
||||
gorm.io/plugin/opentelemetry v0.1.16
|
||||
@@ -230,7 +231,7 @@ require (
|
||||
golang.org/x/mod v0.37.0 // indirect
|
||||
golang.org/x/net v0.56.0 // indirect
|
||||
golang.org/x/oauth2 v0.36.0 // indirect
|
||||
golang.org/x/sys v0.46.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/time v0.15.0 // indirect
|
||||
golang.org/x/tools v0.47.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect
|
||||
|
||||
@@ -22,10 +22,10 @@ github.com/aws/aws-sdk-go-v2 v1.42.1 h1:9eOTgu1z/dVtYpNZ3/8/XbbaX0x/BqE3HUzAzs6K
|
||||
github.com/aws/aws-sdk-go-v2 v1.42.1/go.mod h1:5pKeft2eJj+gElQ38Jqg4ibCqh+/AK33/0X3hip7IjM=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY=
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27 h1:SJwJ9Q4kM7v5QVSYYyXj3znRr6lNyZEhSgAXmXXcVbI=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.27/go.mod h1:uBfrzTRedDmB2u+b6+UlaKJy2O6VSH5un2jP24t/KvQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26 h1:Si8kk1kyJnuJWCEgiwpBtTdtgSdR7i611596NnC0YIQ=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.26/go.mod h1:lBckz+W9SAdNtSDw3pYgQUJDJFcBBWry0GSzw+bK0TY=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29 h1:BcMHHnpiWKogf+gGfpj3K1w+Sktz29XDo/cPSAPO3FU=
|
||||
github.com/aws/aws-sdk-go-v2/config v1.32.29/go.mod h1:+Kbhn8Es4kPUph3F/0W7avykytc+Jh2Ld9/msv9ljV4=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28 h1:zTXJSsNcoO91/mTXsZoYf0AK8dvNPiA58/VtyGXR+wM=
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.19.28/go.mod h1:Kd9E0JzDBW/q1xbsHFrev/GnbAf5J0Ng8xoyc7HZ91Q=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30 h1:/hi1JADLEW9YYryEz1w4GQu0EtP23pP553Cf9KgsDV4=
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.30/go.mod h1:/3AOgy4K17Dm4ucMZVC/MJkzy5kmfKUcINRHZyo0koQ=
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.30 h1:xM/Is9cKMHa8Jj8zkvWhvrFkZsXJV9E+BB4g0HW0duQ=
|
||||
@@ -42,8 +42,8 @@ github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30 h1:/Z5jmNrK
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.30/go.mod h1:lEzEZnOosE7zi8Z6royW1cFJTD9fpab4Ul1SBrllewk=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31 h1:uao4A3QZ5UmB326V6KF+qRpv9Tjz7IlnlnTbbANntlU=
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.31/go.mod h1:I/1+z0VwL1GhQyLgkoHDlygpUZ+iTAwOQ/NsftiUL2I=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2 h1:bAY6O/TDv1HQnvylh9E247IyIKsUWUt2G965S7qX110=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.2/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0 h1:XptwLL+UHXgafYMIHTy59IRovLbhz3znkxY2uS/pbXU=
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.105.0/go.mod h1:zdmCoFO/dSI7GlrwsPqFJI+WlFnSU4Tc8TJnlXrM1Do=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0 h1:sLzmJGCMv+C8KqiJgEqDLB6vxaJGmobRh4rr//ZpA3w=
|
||||
github.com/aws/aws-sdk-go-v2/service/signin v1.4.0/go.mod h1:mxC0nT/C8wMMS97DemZPzvUZxvIt+2Iq+eS3JdFZGgg=
|
||||
github.com/aws/aws-sdk-go-v2/service/sso v1.32.0 h1:qjMmry/cBDee1E/2gyvel0uRYCi3mwRZ2hf6N+GAodo=
|
||||
@@ -161,8 +161,8 @@ github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo=
|
||||
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY=
|
||||
github.com/go-co-op/gocron/v2 v2.21.2/go.mod h1:5lEiCKk1oVJV39Zg7/YG10OnaVrDAV5GGR6O0663k6U=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0 h1:uEuH2F7k7VoESb1BYSaffuuV+T0kkpzsC0aXk7/z79I=
|
||||
github.com/go-co-op/gocron/v2 v2.22.0/go.mod h1:hiH/U9RMhTi1BBZJmef9s3KC9QwhpBF6PFrvUKaXY9M=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-errors/errors v1.0.2/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
github.com/go-errors/errors v1.1.1/go.mod h1:psDX2osz5VnTOnFWbDeWwS7yejl+uV3FEWEp4lssFEs=
|
||||
@@ -348,8 +348,8 @@ github.com/lestrrat-go/option/v2 v2.0.0 h1:XxrcaJESE1fokHy3FpaQ/cXW8ZsIdWcdFzzLO
|
||||
github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg=
|
||||
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
|
||||
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
|
||||
github.com/libtnb/sqlite v1.1.2 h1:3g8nez8MwdEhn+oqt3bfKWnYJxk0ZGTO0kVA1WT+hjQ=
|
||||
github.com/libtnb/sqlite v1.1.2/go.mod h1:2PGCWOR6HqqXuoIVMmwfJ655epvRO8sw0b9kHWuKNsQ=
|
||||
github.com/libtnb/sqlite v1.2.0 h1:XsA7jsXHH2qmFkTWoy5YCKJybzHzWQ0flqDFP5Y9Yto=
|
||||
github.com/libtnb/sqlite v1.2.0/go.mod h1:O6vURH5fa5IgSmXd/qLAL2zSnYFUd7xSIumsmX3BrSI=
|
||||
github.com/lmittmann/tint v1.1.3 h1:Hv4EaHWXQr+GTFnOU4VKf8UvAtZgn0VuKT+G0wFlO3I=
|
||||
github.com/lmittmann/tint v1.1.3/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
@@ -421,6 +421,8 @@ github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
|
||||
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
|
||||
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ=
|
||||
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||
github.com/pires/go-proxyproto v0.15.0 h1:dTshmNbFm/D+0+sbrxUuddPOZ5Y0B7c5NhtsBkm6LqI=
|
||||
github.com/pires/go-proxyproto v0.15.0/go.mod h1:OXsCrKwrK2tXS9YrI5tkHx5xaQlO8FH3lFW76orFh24=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
@@ -616,13 +618,13 @@ golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliY
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||
golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
|
||||
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
|
||||
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
|
||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M=
|
||||
golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY=
|
||||
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
|
||||
golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY=
|
||||
golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q=
|
||||
golang.org/x/image v0.44.0 h1:+tDekMZED9+LrtB3G5xzRggpVh9CARjZqROla3R3R+I=
|
||||
golang.org/x/image v0.44.0/go.mod h1:V8K3KE9KKKE+pLpQDOeN18w9oacNSvy1tDOirTu4xtY=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
@@ -686,8 +688,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
|
||||
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
@@ -709,8 +711,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
|
||||
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
|
||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
|
||||
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
||||
55
backend/internal/bootstrap/proxy_protocol.go
Normal file
55
backend/internal/bootstrap/proxy_protocol.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"time"
|
||||
|
||||
"github.com/pires/go-proxyproto"
|
||||
)
|
||||
|
||||
const proxyProtocolReadHeaderTimeout = 5 * time.Second
|
||||
|
||||
func newProxyProtocolListener(listener net.Listener, trustedProxies []string) (net.Listener, error) {
|
||||
if len(trustedProxies) == 0 {
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
// PROXY protocol transports client metadata on TCP connections before any TLS handshake
|
||||
network := listener.Addr().Network()
|
||||
if network != "tcp" && network != "tcp4" && network != "tcp6" {
|
||||
return nil, fmt.Errorf("PROXY protocol requires a TCP listener, got %q", network)
|
||||
}
|
||||
|
||||
// Require headers from configured network proxies and reject every other network peer
|
||||
trustedProxyPolicy, err := proxyproto.TrustProxyHeaderFromRanges(trustedProxies)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to configure PROXY protocol trusted proxies: %w", err)
|
||||
}
|
||||
|
||||
// We need to add another policy for loopback connections because healthcheck requests are made from the same host
|
||||
// loopbackPolicy handles requests from trusted loopback like normal, but it also allows untrusted loopback connections to be accepted
|
||||
// untrusted loopback connections aren't allowed to send PROXY headers though
|
||||
loopbackPolicy, err := proxyproto.PolicyFromRanges(trustedProxies, proxyproto.USE, proxyproto.SKIP)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to configure PROXY protocol loopback proxies: %w", err)
|
||||
}
|
||||
|
||||
// Create a policy which decides which of the two policies to use based on the upstream address
|
||||
policy := func(options proxyproto.ConnPolicyOptions) (proxyproto.Policy, error) {
|
||||
upstream, ok := options.Upstream.(*net.TCPAddr)
|
||||
if ok && upstream.IP.IsLoopback() {
|
||||
return loopbackPolicy(options)
|
||||
}
|
||||
|
||||
return trustedProxyPolicy(options)
|
||||
}
|
||||
|
||||
slog.Info("PROXY protocol enabled")
|
||||
return &proxyproto.Listener{
|
||||
Listener: listener,
|
||||
ConnPolicy: policy,
|
||||
ReadHeaderTimeout: proxyProtocolReadHeaderTimeout,
|
||||
}, nil
|
||||
}
|
||||
486
backend/internal/bootstrap/proxy_protocol_test.go
Normal file
486
backend/internal/bootstrap/proxy_protocol_test.go
Normal file
@@ -0,0 +1,486 @@
|
||||
package bootstrap
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"io"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/pires/go-proxyproto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewProxyProtocolListenerDisabled(t *testing.T) {
|
||||
listener, _ := newProxyProtocolTestConnection(t, "192.0.2.10")
|
||||
|
||||
wrapped, err := newProxyProtocolListener(listener, nil)
|
||||
|
||||
require.NoError(t, err)
|
||||
assert.Same(t, listener, wrapped)
|
||||
}
|
||||
|
||||
func TestNewProxyProtocolListenerRequiresTCP(t *testing.T) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
t.Cleanup(func() {
|
||||
_ = serverConn.Close()
|
||||
_ = clientConn.Close()
|
||||
})
|
||||
listener := &singleConnListener{
|
||||
conn: serverConn,
|
||||
addr: &net.UnixAddr{Name: "/tmp/pocket-id.sock", Net: "unix"},
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
_, err := newProxyProtocolListener(listener, []string{"127.0.0.1"})
|
||||
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "PROXY protocol requires a TCP listener")
|
||||
}
|
||||
|
||||
func TestNewProxyProtocolListenerUsesStrictTrustedProxyPolicy(t *testing.T) {
|
||||
listener, _ := newProxyProtocolTestConnection(t, "192.0.2.10")
|
||||
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
proxyListener := requireProxyProtocolListener(t, wrapped)
|
||||
assert.Equal(t, proxyProtocolReadHeaderTimeout, proxyListener.ReadHeaderTimeout)
|
||||
|
||||
policy, err := proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{
|
||||
Upstream: &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 12345},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, proxyproto.REQUIRE, policy)
|
||||
|
||||
_, err = proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{
|
||||
Upstream: &net.TCPAddr{IP: net.ParseIP("198.51.100.10"), Port: 12345},
|
||||
})
|
||||
require.ErrorIs(t, err, proxyproto.ErrInvalidUpstream)
|
||||
|
||||
policy, err = proxyListener.ConnPolicy(proxyproto.ConnPolicyOptions{
|
||||
Upstream: &net.TCPAddr{IP: net.ParseIP("127.0.0.1"), Port: 12345},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, proxyproto.SKIP, policy)
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerAllowsLoopbackWithoutHeader(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "127.0.0.1")
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverConn, err := wrapped.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := clientConn.Write([]byte("healthcheck"))
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
payload := make([]byte, len("healthcheck"))
|
||||
_, err = io.ReadFull(serverConn, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-writeErr)
|
||||
assert.Equal(t, "healthcheck", string(payload))
|
||||
assert.Equal(t, "127.0.0.1:12345", serverConn.RemoteAddr().String())
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerAcceptsHeaderFromTrustedLoopback(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "::1")
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"::1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverConn, err := wrapped.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
source := &net.TCPAddr{IP: net.ParseIP("2001:db8::15"), Port: 42300}
|
||||
dest := &net.TCPAddr{IP: net.ParseIP("::1"), Port: 1411}
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
header := proxyproto.HeaderProxyFromAddrs(2, source, dest)
|
||||
if _, err := header.WriteTo(clientConn); err != nil {
|
||||
writeErr <- err
|
||||
return
|
||||
}
|
||||
_, err := clientConn.Write([]byte("payload"))
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
payload := make([]byte, len("payload"))
|
||||
_, err = io.ReadFull(serverConn, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-writeErr)
|
||||
assert.Equal(t, "payload", string(payload))
|
||||
assert.Equal(t, source.String(), serverConn.RemoteAddr().String())
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerAllowsTrustedLoopbackWithoutHeader(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "::1")
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"::1"})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverConn, err := wrapped.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := clientConn.Write([]byte("healthcheck"))
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
payload := make([]byte, len("healthcheck"))
|
||||
_, err = io.ReadFull(serverConn, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-writeErr)
|
||||
assert.Equal(t, "healthcheck", string(payload))
|
||||
assert.Equal(t, "[::1]:12345", serverConn.RemoteAddr().String())
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerDropsUntrustedPeerAndContinues(t *testing.T) {
|
||||
untrustedServerConn, untrustedClientConn := newAddressedPipe("198.51.100.10")
|
||||
trustedServerConn, trustedClientConn := newAddressedPipe("192.0.2.10")
|
||||
listener := &queuedConnListener{
|
||||
conns: []net.Conn{untrustedServerConn, trustedServerConn},
|
||||
addr: trustedServerConn.LocalAddr(),
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
_ = untrustedServerConn.Close()
|
||||
_ = trustedServerConn.Close()
|
||||
_ = untrustedClientConn.Close()
|
||||
_ = trustedClientConn.Close()
|
||||
})
|
||||
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
serverConn, err := wrapped.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}
|
||||
dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
header := proxyproto.HeaderProxyFromAddrs(1, source, dest)
|
||||
if _, err := header.WriteTo(trustedClientConn); err != nil {
|
||||
writeErr <- err
|
||||
return
|
||||
}
|
||||
_, err := trustedClientConn.Write([]byte("payload"))
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
payload := make([]byte, len("payload"))
|
||||
_, err = io.ReadFull(serverConn, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-writeErr)
|
||||
assert.Equal(t, source.String(), serverConn.RemoteAddr().String())
|
||||
|
||||
_, err = untrustedClientConn.Write([]byte("payload"))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerExtractsClientAddress(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
version byte
|
||||
source *net.TCPAddr
|
||||
dest *net.TCPAddr
|
||||
}{
|
||||
{
|
||||
name: "version 1 IPv4",
|
||||
version: 1,
|
||||
source: &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300},
|
||||
dest: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443},
|
||||
},
|
||||
{
|
||||
name: "version 2 IPv6",
|
||||
version: 2,
|
||||
source: &net.TCPAddr{IP: net.ParseIP("2001:db8::15"), Port: 42300},
|
||||
dest: &net.TCPAddr{IP: net.ParseIP("2001:db8::20"), Port: 443},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverConn, err := wrapped.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
header := proxyproto.HeaderProxyFromAddrs(testCase.version, testCase.source, testCase.dest)
|
||||
if _, err := header.WriteTo(clientConn); err != nil {
|
||||
writeErr <- err
|
||||
return
|
||||
}
|
||||
_, err := clientConn.Write([]byte("payload"))
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
payload := make([]byte, len("payload"))
|
||||
_, err = io.ReadFull(serverConn, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-writeErr)
|
||||
assert.Equal(t, "payload", string(payload))
|
||||
assert.Equal(t, testCase.source.String(), serverConn.RemoteAddr().String())
|
||||
assert.Equal(t, testCase.dest.String(), serverConn.LocalAddr().String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerRejectsMissingHeader(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverConn, err := wrapped.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
go func() {
|
||||
_, _ = clientConn.Write([]byte("GET /healthz HTTP/1.1\r\n"))
|
||||
}()
|
||||
|
||||
_, err = serverConn.Read(make([]byte, 1))
|
||||
require.ErrorIs(t, err, proxyproto.ErrNoProxyProtocol)
|
||||
}
|
||||
|
||||
func TestProxyProtocolListenerSetsGinClientIP(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
|
||||
wrapped, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
|
||||
engine := gin.New()
|
||||
require.NoError(t, engine.SetTrustedProxies(nil))
|
||||
clientIP := make(chan string, 1)
|
||||
engine.GET("/client-ip", func(c *gin.Context) {
|
||||
clientIP <- c.ClientIP()
|
||||
c.Status(http.StatusNoContent)
|
||||
})
|
||||
server := &http.Server{
|
||||
Handler: engine,
|
||||
ReadHeaderTimeout: time.Second,
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = server.Close()
|
||||
})
|
||||
go func() {
|
||||
_ = server.Serve(wrapped)
|
||||
}()
|
||||
|
||||
source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}
|
||||
dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}
|
||||
writeErr := make(chan error, 1)
|
||||
go func() {
|
||||
header := proxyproto.HeaderProxyFromAddrs(1, source, dest)
|
||||
if _, err := header.WriteTo(clientConn); err != nil {
|
||||
writeErr <- err
|
||||
return
|
||||
}
|
||||
_, err := clientConn.Write([]byte("GET /client-ip HTTP/1.1\r\nHost: pocket-id.example\r\nConnection: close\r\n\r\n"))
|
||||
writeErr <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case actualClientIP := <-clientIP:
|
||||
assert.Equal(t, "203.0.113.15", actualClientIP)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("timed out waiting for Gin to handle the proxied request")
|
||||
}
|
||||
require.NoError(t, <-writeErr)
|
||||
}
|
||||
|
||||
func TestProxyProtocolHeaderIsReadBeforeTLS(t *testing.T) {
|
||||
listener, clientConn := newProxyProtocolTestConnection(t, "192.0.2.10")
|
||||
proxyListener, err := newProxyProtocolListener(listener, []string{"192.0.2.0/24"})
|
||||
require.NoError(t, err)
|
||||
|
||||
serverTLSConfig, clientTLSConfig := newProxyProtocolTestTLSConfigs(t)
|
||||
tlsListener := tls.NewListener(proxyListener, serverTLSConfig)
|
||||
serverConn, err := tlsListener.Accept()
|
||||
require.NoError(t, err)
|
||||
|
||||
source := &net.TCPAddr{IP: net.ParseIP("203.0.113.15"), Port: 42300}
|
||||
dest := &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 443}
|
||||
ctx := t.Context()
|
||||
clientErr := make(chan error, 1)
|
||||
go func() {
|
||||
header := proxyproto.HeaderProxyFromAddrs(2, source, dest)
|
||||
if _, err := header.WriteTo(clientConn); err != nil {
|
||||
clientErr <- err
|
||||
return
|
||||
}
|
||||
|
||||
tlsClient := tls.Client(clientConn, clientTLSConfig)
|
||||
if err := tlsClient.HandshakeContext(ctx); err != nil {
|
||||
clientErr <- err
|
||||
return
|
||||
}
|
||||
_, err := tlsClient.Write([]byte("payload"))
|
||||
clientErr <- err
|
||||
}()
|
||||
|
||||
tlsServerConn, ok := serverConn.(*tls.Conn)
|
||||
require.True(t, ok)
|
||||
require.NoError(t, tlsServerConn.HandshakeContext(ctx))
|
||||
payload := make([]byte, len("payload"))
|
||||
_, err = io.ReadFull(serverConn, payload)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, <-clientErr)
|
||||
assert.Equal(t, "payload", string(payload))
|
||||
assert.Equal(t, source.String(), serverConn.RemoteAddr().String())
|
||||
}
|
||||
|
||||
func requireProxyProtocolListener(t *testing.T, listener net.Listener) *proxyproto.Listener {
|
||||
t.Helper()
|
||||
|
||||
proxyListener, ok := listener.(*proxyproto.Listener)
|
||||
require.True(t, ok)
|
||||
return proxyListener
|
||||
}
|
||||
|
||||
func newProxyProtocolTestConnection(t *testing.T, upstreamIP string) (*singleConnListener, net.Conn) {
|
||||
t.Helper()
|
||||
|
||||
serverConn, clientConn := newAddressedPipe(upstreamIP)
|
||||
listener := &singleConnListener{
|
||||
conn: serverConn,
|
||||
addr: serverConn.LocalAddr(),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
_ = listener.Close()
|
||||
_ = clientConn.Close()
|
||||
})
|
||||
return listener, clientConn
|
||||
}
|
||||
|
||||
func newAddressedPipe(upstreamIP string) (net.Conn, net.Conn) {
|
||||
serverConn, clientConn := net.Pipe()
|
||||
return &addressedConn{
|
||||
Conn: serverConn,
|
||||
localAddr: &net.TCPAddr{IP: net.ParseIP("192.0.2.20"), Port: 1411},
|
||||
remoteAddr: &net.TCPAddr{IP: net.ParseIP(upstreamIP), Port: 12345},
|
||||
}, clientConn
|
||||
}
|
||||
|
||||
func newProxyProtocolTestTLSConfigs(t *testing.T) (*tls.Config, *tls.Config) {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
certificate, err := x509.ParseCertificate(certificateDER)
|
||||
require.NoError(t, err)
|
||||
|
||||
roots := x509.NewCertPool()
|
||||
roots.AddCert(certificate)
|
||||
|
||||
serverConfig := &tls.Config{
|
||||
Certificates: []tls.Certificate{{
|
||||
Certificate: [][]byte{certificateDER},
|
||||
PrivateKey: privateKey,
|
||||
}},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
clientConfig := &tls.Config{
|
||||
RootCAs: roots,
|
||||
ServerName: "localhost",
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
return serverConfig, clientConfig
|
||||
}
|
||||
|
||||
type singleConnListener struct {
|
||||
conn net.Conn
|
||||
addr net.Addr
|
||||
accepted bool
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
func (l *singleConnListener) Accept() (net.Conn, error) {
|
||||
if l.accepted {
|
||||
<-l.closed
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
l.accepted = true
|
||||
return l.conn, nil
|
||||
}
|
||||
|
||||
func (l *singleConnListener) Close() error {
|
||||
var closeErr error
|
||||
l.closeOnce.Do(func() {
|
||||
close(l.closed)
|
||||
closeErr = l.conn.Close()
|
||||
})
|
||||
return closeErr
|
||||
}
|
||||
|
||||
func (l *singleConnListener) Addr() net.Addr {
|
||||
return l.addr
|
||||
}
|
||||
|
||||
type addressedConn struct {
|
||||
net.Conn
|
||||
localAddr net.Addr
|
||||
remoteAddr net.Addr
|
||||
}
|
||||
|
||||
func (c *addressedConn) LocalAddr() net.Addr {
|
||||
return c.localAddr
|
||||
}
|
||||
|
||||
func (c *addressedConn) RemoteAddr() net.Addr {
|
||||
return c.remoteAddr
|
||||
}
|
||||
|
||||
type queuedConnListener struct {
|
||||
conns []net.Conn
|
||||
addr net.Addr
|
||||
}
|
||||
|
||||
func (l *queuedConnListener) Accept() (net.Conn, error) {
|
||||
if len(l.conns) == 0 {
|
||||
return nil, net.ErrClosed
|
||||
}
|
||||
|
||||
conn := l.conns[0]
|
||||
l.conns = l.conns[1:]
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func (l *queuedConnListener) Close() error {
|
||||
for _, conn := range l.conns {
|
||||
_ = conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (l *queuedConnListener) Addr() net.Addr {
|
||||
return l.addr
|
||||
}
|
||||
@@ -222,7 +222,18 @@ func initServer(r *gin.Engine) (*serverConfig, error) {
|
||||
}
|
||||
|
||||
addr := socket.addr
|
||||
|
||||
listener := socket.listener
|
||||
|
||||
// Wrap the listener with a proxy protocol listener if configured and not using a Unix socket
|
||||
if len(common.EnvConfig.ProxyProtocol) > 0 && common.EnvConfig.UnixSocket == "" {
|
||||
listener, err = newProxyProtocolListener(socket.listener, common.EnvConfig.ProxyProtocol)
|
||||
if err != nil {
|
||||
_ = socket.listener.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
server := newHTTPServer(r, protocols)
|
||||
|
||||
return &serverConfig{addr, certProvider, listener, server, tlsConfig}, nil
|
||||
|
||||
@@ -2,6 +2,7 @@ package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
@@ -64,13 +65,27 @@ func init() {
|
||||
},
|
||||
}
|
||||
|
||||
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", "http://localhost:"+common.EnvConfig.Port, "Endpoint for Pocket ID")
|
||||
healthcheckCmd.Flags().StringVarP(&flags.Endpoint, "endpoint", "e", defaultEndpoint(), "Endpoint for Pocket ID")
|
||||
healthcheckCmd.Flags().StringVar(&flags.UnixSocket, "unix-socket", "", "UNIX socket path for Pocket ID")
|
||||
healthcheckCmd.Flags().BoolVarP(&flags.Verbose, "verbose", "v", false, "Enable verbose mode")
|
||||
|
||||
rootCmd.AddCommand(healthcheckCmd)
|
||||
}
|
||||
|
||||
// The server only serves TLS when both a certificate and a key file are configured
|
||||
func tlsEnabled() bool {
|
||||
return common.EnvConfig.TLSCertFile != "" && common.EnvConfig.TLSKeyFile != ""
|
||||
}
|
||||
|
||||
func defaultEndpoint() string {
|
||||
scheme := "http"
|
||||
if tlsEnabled() {
|
||||
scheme = "https"
|
||||
}
|
||||
|
||||
return scheme + "://localhost:" + common.EnvConfig.Port
|
||||
}
|
||||
|
||||
func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResult, error) {
|
||||
url := strings.TrimRight(flags.Endpoint, "/") + "/healthz"
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
@@ -78,17 +93,25 @@ func healthcheck(ctx context.Context, flags healthcheckFlags) (*healthcheckResul
|
||||
return nil, fmt.Errorf("failed to create request object for %q: %w", url, err)
|
||||
}
|
||||
|
||||
client := http.DefaultClient
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
|
||||
if flags.UnixSocket != "" {
|
||||
transport := http.DefaultTransport.(*http.Transport).Clone()
|
||||
transport.Proxy = nil
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
dialer := net.Dialer{}
|
||||
return dialer.DialContext(ctx, "unix", flags.UnixSocket)
|
||||
}
|
||||
client = &http.Client{Transport: transport}
|
||||
}
|
||||
|
||||
// If TLS cert and key files are provided, there is a high chance that the server is using a self-signed certificate
|
||||
if tlsEnabled() {
|
||||
transport.TLSClientConfig = &tls.Config{
|
||||
InsecureSkipVerify: true, //nolint:gosec // no sensitive data is transmitted in healthcheck
|
||||
}
|
||||
}
|
||||
|
||||
client := &http.Client{Transport: transport}
|
||||
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to perform request to %q: %w", url, err)
|
||||
|
||||
@@ -2,15 +2,24 @@ package cmds
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/common"
|
||||
)
|
||||
|
||||
func TestHealthcheckTCPSuccess(t *testing.T) {
|
||||
@@ -77,3 +86,123 @@ func TestHealthcheckUnixSocket(t *testing.T) {
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
require.Equal(t, "http://localhost:1411/healthz", result.URL)
|
||||
}
|
||||
|
||||
// The server also serves TLS over a UNIX socket, so the healthcheck must keep dialing
|
||||
// the socket instead of falling back to the host in the endpoint
|
||||
func TestHealthcheckUnixSocketWithTLS(t *testing.T) {
|
||||
setTLSFiles(t)
|
||||
|
||||
socketPath := filepath.Join(shortTempDir(t), "s.sock")
|
||||
listener, err := (&net.ListenConfig{}).Listen(t.Context(), "unix", socketPath)
|
||||
require.NoError(t, err)
|
||||
|
||||
server := &http.Server{
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/healthz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}),
|
||||
ReadHeaderTimeout: time.Second,
|
||||
TLSConfig: newSelfSignedTLSConfig(t),
|
||||
}
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- server.ServeTLS(listener, "", "")
|
||||
}()
|
||||
|
||||
// The connection is upgraded to HTTP/2, which keeps a graceful shutdown waiting for the idle client connection
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, server.Close())
|
||||
require.ErrorIs(t, <-errCh, http.ErrServerClosed)
|
||||
})
|
||||
|
||||
result, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: "https://localhost:1411",
|
||||
UnixSocket: socketPath,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
}
|
||||
|
||||
// The healthcheck must reach a server that serves TLS with a self-signed certificate
|
||||
func TestHealthcheckTLSSelfSignedCertificate(t *testing.T) {
|
||||
setTLSFiles(t)
|
||||
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "/healthz", r.URL.Path)
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
result, err := healthcheck(t.Context(), healthcheckFlags{
|
||||
Endpoint: server.URL,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusNoContent, result.StatusCode)
|
||||
}
|
||||
|
||||
func TestDefaultEndpoint(t *testing.T) {
|
||||
t.Run("uses http if no TLS certificate is configured", func(t *testing.T) {
|
||||
assert.Equal(t, "http://localhost:"+common.EnvConfig.Port, defaultEndpoint())
|
||||
})
|
||||
|
||||
t.Run("uses https if a TLS certificate is configured", func(t *testing.T) {
|
||||
setTLSFiles(t)
|
||||
assert.Equal(t, "https://localhost:"+common.EnvConfig.Port, defaultEndpoint())
|
||||
})
|
||||
}
|
||||
|
||||
// t.TempDir embeds the test name, which can exceed the maximum socket path length
|
||||
func shortTempDir(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
dir, err := os.MkdirTemp("", "pid")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() {
|
||||
_ = os.RemoveAll(dir)
|
||||
})
|
||||
|
||||
return dir
|
||||
}
|
||||
|
||||
// Only the presence of the paths matters, the healthcheck never reads the files
|
||||
func setTLSFiles(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
certFile, keyFile := common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile
|
||||
t.Cleanup(func() {
|
||||
common.EnvConfig.TLSCertFile, common.EnvConfig.TLSKeyFile = certFile, keyFile
|
||||
})
|
||||
|
||||
common.EnvConfig.TLSCertFile = "cert.pem"
|
||||
common.EnvConfig.TLSKeyFile = "key.pem"
|
||||
}
|
||||
|
||||
func newSelfSignedTLSConfig(t *testing.T) *tls.Config {
|
||||
t.Helper()
|
||||
|
||||
privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now()
|
||||
template := &x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
DNSNames: []string{"localhost"},
|
||||
NotBefore: now.Add(-time.Minute),
|
||||
NotAfter: now.Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
}
|
||||
certificateDER, err := x509.CreateCertificate(rand.Reader, template, template, &privateKey.PublicKey, privateKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{{
|
||||
Certificate: [][]byte{certificateDER},
|
||||
PrivateKey: privateKey,
|
||||
}},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ type EnvConfigSchema struct {
|
||||
DbProvider DbProvider
|
||||
DbConnectionString string `env:"DB_CONNECTION_STRING" options:"file"`
|
||||
TrustProxy TrustProxyConfig `env:"TRUST_PROXY"`
|
||||
ProxyProtocol TrustProxyConfig `env:"PROXY_PROTOCOL"`
|
||||
TrustedPlatform string `env:"TRUSTED_PLATFORM"`
|
||||
AuditLogRetentionDays int `env:"AUDIT_LOG_RETENTION_DAYS"`
|
||||
AnalyticsDisabled bool `env:"ANALYTICS_DISABLED"`
|
||||
@@ -158,6 +159,9 @@ func ValidateEnvConfig(config *EnvConfigSchema) error {
|
||||
if config.SystemdSocket && config.UnixSocket != "" {
|
||||
return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive")
|
||||
}
|
||||
if len(config.ProxyProtocol) > 0 && config.UnixSocket != "" {
|
||||
return errors.New("PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive")
|
||||
}
|
||||
|
||||
if config.AuditLogRetentionDays <= 0 {
|
||||
return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0")
|
||||
@@ -414,6 +418,11 @@ func (config *TrustProxyConfig) UnmarshalText(text []byte) error {
|
||||
proxies := strings.Split(value, ",")
|
||||
for i, proxy := range proxies {
|
||||
proxy = strings.TrimSpace(proxy)
|
||||
if net.ParseIP(proxy) == nil {
|
||||
if _, _, err := net.ParseCIDR(proxy); err != nil {
|
||||
return fmt.Errorf("invalid proxy IP address or CIDR %q", proxy)
|
||||
}
|
||||
}
|
||||
proxies[i] = proxy
|
||||
}
|
||||
|
||||
|
||||
@@ -118,6 +118,7 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
t.Setenv("METRICS_ENABLED", "true")
|
||||
t.Setenv("TRACING_ENABLED", "false")
|
||||
t.Setenv("TRUST_PROXY", "true")
|
||||
t.Setenv("PROXY_PROTOCOL", "true")
|
||||
t.Setenv("ANALYTICS_DISABLED", "false")
|
||||
t.Setenv("ALLOW_INSECURE_CALLBACK_URLS", "false")
|
||||
|
||||
@@ -125,6 +126,7 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.True(t, EnvConfig.UiConfigDisabled)
|
||||
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.TrustProxy)
|
||||
assert.Equal(t, TrustProxyConfig{"0.0.0.0/0", "::/0"}, EnvConfig.ProxyProtocol)
|
||||
assert.False(t, EnvConfig.AnalyticsDisabled)
|
||||
assert.False(t, EnvConfig.AllowInsecureCallbackURLs)
|
||||
})
|
||||
@@ -147,6 +149,34 @@ func TestParseEnvConfig(t *testing.T) {
|
||||
assert.Nil(t, EnvConfig.TrustProxy)
|
||||
})
|
||||
|
||||
t.Run("should parse PROXY protocol trusted proxy IP addresses and CIDR ranges", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("PROXY_PROTOCOL", "10.0.0.0/8, 192.168.1.10, ::1/128")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, TrustProxyConfig{"10.0.0.0/8", "192.168.1.10", "::1/128"}, EnvConfig.ProxyProtocol)
|
||||
})
|
||||
|
||||
t.Run("should reject an invalid PROXY protocol trusted proxy", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("PROXY_PROTOCOL", "not-an-ip")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "invalid proxy IP address or CIDR")
|
||||
})
|
||||
|
||||
t.Run("should reject PROXY protocol with a UNIX socket", func(t *testing.T) {
|
||||
EnvConfig = defaultConfig()
|
||||
t.Setenv("PROXY_PROTOCOL", "true")
|
||||
t.Setenv("UNIX_SOCKET", "/tmp/pocket-id.sock")
|
||||
|
||||
err := parseAndValidateEnvConfig(t)
|
||||
require.Error(t, err)
|
||||
assert.ErrorContains(t, err, "PROXY_PROTOCOL and UNIX_SOCKET are mutually exclusive")
|
||||
})
|
||||
|
||||
t.Run("should allow insecure callback URLs by default", func(t *testing.T) {
|
||||
assert.True(t, defaultConfig().AllowInsecureCallbackURLs)
|
||||
})
|
||||
|
||||
@@ -301,15 +301,26 @@ func (s *UserService) CreateUserInternal(ctx context.Context, input dto.UserCrea
|
||||
return model.User{}, err
|
||||
}
|
||||
|
||||
// Bump the UpdatedAt timestamp of the groups the new user was added to
|
||||
// This is necessary for SCIM to work with the newly-created user, or groups may not be synced via SCIM
|
||||
if len(userGroups) > 0 {
|
||||
err = s.touchUserGroups(ctx, tx, groupIDs(userGroups))
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
// Apply default groups and claims for new non-LDAP users
|
||||
if !isLdapSync {
|
||||
if len(input.UserGroupIds) == 0 {
|
||||
if err := s.applyDefaultGroups(ctx, &user, tx); err != nil {
|
||||
err = s.applyDefaultGroups(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyDefaultCustomClaims(ctx, &user, tx); err != nil {
|
||||
err = s.applyDefaultCustomClaims(ctx, &user, tx)
|
||||
if err != nil {
|
||||
return model.User{}, err
|
||||
}
|
||||
}
|
||||
@@ -348,11 +359,43 @@ func (s *UserService) applyDefaultGroups(ctx context.Context, user *model.User,
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to associate default user groups: %w", err)
|
||||
}
|
||||
|
||||
// Bump the groups' UpdatedAt so the SCIM sync picks up the new
|
||||
// membership (see touchUserGroups for details).
|
||||
touchIDs := make([]string, len(groups))
|
||||
for i := range groups {
|
||||
touchIDs[i] = groups[i].ID
|
||||
}
|
||||
if err := s.touchUserGroups(ctx, tx, touchIDs); err != nil {
|
||||
return fmt.Errorf("failed to update default user groups timestamp: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// touchUserGroups updates the UpdatedAt timestamp of the given user groups.
|
||||
//
|
||||
// Group membership is stored in the user_groups_users join table, so adding or
|
||||
// removing a member does not modify the group row itself. The SCIM sync only
|
||||
// re-pushes a group to the provider when its UpdatedAt is not older than the
|
||||
// remote resource's last-modified time, so any code path that changes a group's
|
||||
// membership must bump this timestamp explicitly. Otherwise the membership
|
||||
// change is never synced to the SCIM provider.
|
||||
func (s *UserService) touchUserGroups(ctx context.Context, tx *gorm.DB, ids []string) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
now := datatype.DateTime(time.Now())
|
||||
return tx.
|
||||
WithContext(ctx).
|
||||
Model(&model.UserGroup{}).
|
||||
Where("id IN ?", ids).
|
||||
Update("updated_at", now).
|
||||
Error
|
||||
}
|
||||
|
||||
func (s *UserService) applyDefaultCustomClaims(ctx context.Context, user *model.User, tx *gorm.DB) error {
|
||||
config := s.appConfigService.GetDbConfig()
|
||||
|
||||
|
||||
104
backend/internal/service/user_service_test.go
Normal file
104
backend/internal/service/user_service_test.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/pocket-id/pocket-id/backend/internal/dto"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/model"
|
||||
"github.com/pocket-id/pocket-id/backend/internal/storage"
|
||||
testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing"
|
||||
)
|
||||
|
||||
func newTestUserService(t *testing.T, appConfig *AppConfigService) (*UserService, *UserGroupService) {
|
||||
t.Helper()
|
||||
|
||||
db := testutils.NewDatabaseForTest(t)
|
||||
|
||||
fileStorage, err := storage.NewDatabaseStorage(db)
|
||||
require.NoError(t, err)
|
||||
|
||||
userService := NewUserService(
|
||||
db,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
appConfig,
|
||||
NewCustomClaimService(db),
|
||||
NewAppImagesService(map[string]string{}, fileStorage),
|
||||
nil,
|
||||
fileStorage,
|
||||
)
|
||||
groupService := NewUserGroupService(db, appConfig, nil)
|
||||
|
||||
return userService, groupService
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsGroupUpdatedAt(t *testing.T) {
|
||||
appConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
})
|
||||
userService, groupService := newTestUserService(t, appConfig)
|
||||
|
||||
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
|
||||
Name: "members",
|
||||
FriendlyName: "Members",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, group.UpdatedAt, "a freshly created group has no UpdatedAt yet")
|
||||
|
||||
// Create a user that is a member of the group
|
||||
// This mirrors signing up via an invite link that adds the user to a group
|
||||
email := "member@example.com"
|
||||
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
|
||||
Username: "member",
|
||||
Email: &email,
|
||||
FirstName: "Group",
|
||||
LastName: "Member",
|
||||
UserGroupIds: []string{group.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// The group's UpdatedAt must now be set
|
||||
updated, err := groupService.Get(t.Context(), group.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated.UpdatedAt, "creating a group member must bump the group's UpdatedAt")
|
||||
require.False(t, updated.LastModified().Before(updated.CreatedAt.ToTime()), "group LastModified must not predate its CreatedAt after a membership change")
|
||||
require.Len(t, updated.Users, 1, "the user should be a member of the group")
|
||||
}
|
||||
|
||||
func TestCreateUserBumpsDefaultGroupUpdatedAt(t *testing.T) {
|
||||
appConfig := NewTestAppConfigService(&model.AppConfig{
|
||||
RequireUserEmail: model.AppConfigVariable{Value: "false"},
|
||||
})
|
||||
userService, groupService := newTestUserService(t, appConfig)
|
||||
|
||||
group, err := groupService.Create(t.Context(), dto.UserGroupCreateDto{
|
||||
Name: "default",
|
||||
FriendlyName: "Default",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Nil(t, group.UpdatedAt)
|
||||
|
||||
// Configure the group as a default signup group
|
||||
defaultGroups, err := json.Marshal([]string{group.ID})
|
||||
require.NoError(t, err)
|
||||
appConfig.dbConfig.Load().SignupDefaultUserGroupIDs.Value = string(defaultGroups)
|
||||
|
||||
// Create a user without explicit group IDs, so the default groups apply
|
||||
email := "default@example.com"
|
||||
_, err = userService.CreateUser(t.Context(), dto.UserCreateDto{
|
||||
Username: "defaultmember",
|
||||
Email: &email,
|
||||
FirstName: "Default",
|
||||
LastName: "Member",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
updated, err := groupService.Get(t.Context(), group.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated.UpdatedAt, "adding a default group member must bump the group's UpdatedAt")
|
||||
require.Len(t, updated.Users, 1)
|
||||
}
|
||||
@@ -19,7 +19,7 @@
|
||||
"@types/node": "^25.9.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react-email": "6.6.6",
|
||||
"react-email": "6.7.0",
|
||||
"tsx": "^4.22.2"
|
||||
}
|
||||
}
|
||||
|
||||
682
pnpm-lock.yaml
generated
682
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user