diff --git a/backend/go.mod b/backend/go.mod index f835ecd1..a2de275b 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,6 +1,6 @@ module github.com/pocket-id/pocket-id/backend -go 1.26 +go 1.26.4 require ( github.com/aws/aws-sdk-go-v2 v1.42.0 @@ -19,8 +19,6 @@ 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/glebarez/go-sqlite v1.22.0 - github.com/glebarez/sqlite v1.11.0 github.com/go-co-op/gocron/v2 v2.21.2 github.com/go-jose/go-jose/v4 v4.1.4 github.com/go-ldap/ldap/v3 v3.4.13 @@ -29,10 +27,14 @@ require ( github.com/golang-migrate/migrate/v4 v4.19.1 github.com/google/uuid v1.6.0 github.com/hashicorp/go-uuid v1.0.3 + github.com/italypaleale/francis v0.1.0-beta.6 + github.com/italypaleale/go-kit v0.0.0-20260703002252-b413738a463f + github.com/jackc/pgx/v5 v5.10.0 github.com/jinzhu/copier v0.4.0 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/lmittmann/tint v1.1.3 github.com/mattn/go-isatty v0.0.22 github.com/mileusna/useragent v1.3.5 @@ -57,13 +59,14 @@ require ( golang.org/x/image v0.42.0 golang.org/x/sync v0.21.0 golang.org/x/text v0.38.0 - golang.org/x/time v0.15.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 + modernc.org/sqlite v1.53.0 ) require ( github.com/Azure/go-ntlmssp v0.1.1 // indirect + github.com/alphadose/haxmap v1.4.1 // indirect github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.29 // indirect @@ -98,8 +101,9 @@ require ( github.com/dsoprea/go-photoshop-info-format v0.0.0-20200610045659-121dd752914d // indirect github.com/dsoprea/go-png-image-structure v0.0.0-20210512210324-29b889a6093d // indirect github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349 // indirect + github.com/dunglas/httpsfv v1.1.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/gabriel-vasile/mimetype v1.4.13 // indirect github.com/gin-contrib/sse v1.1.1 // indirect @@ -127,9 +131,10 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/italypaleale/go-sql-utils v0.2.4-0.20260702021114-e1dfdfb67d0e // indirect + github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect - github.com/jackc/pgx/v5 v5.9.1 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect github.com/jaegertracing/jaeger-idl v0.9.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect @@ -163,11 +168,12 @@ require ( github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect - github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/common v0.69.0 // indirect github.com/prometheus/otlptranslator v1.0.0 // indirect - github.com/prometheus/procfs v0.20.1 // indirect + github.com/prometheus/procfs v0.21.1 // indirect github.com/quic-go/qpack v0.6.0 // indirect - github.com/quic-go/quic-go v0.59.1 // indirect + github.com/quic-go/quic-go v0.60.0 // indirect + github.com/quic-go/webtransport-go v0.10.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/sagikazarmark/locafero v0.12.0 // indirect @@ -183,6 +189,8 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect github.com/valyala/fastjson v1.6.10 // indirect + github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect + github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect @@ -206,25 +214,25 @@ require ( go.opentelemetry.io/otel/exporters/zipkin v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/mock v0.6.0 // indirect - go.yaml.in/yaml/v2 v2.4.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.27.0 // indirect - golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect golang.org/x/mod v0.37.0 // indirect - golang.org/x/net v0.55.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/tools v0.45.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab // indirect - google.golang.org/grpc v1.81.1 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.46.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 // indirect + google.golang.org/grpc v1.82.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.71.0 // indirect + k8s.io/utils v0.0.0-20260617174310-a95e086a2553 // indirect + modernc.org/libc v1.73.5 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect - modernc.org/sqlite v1.48.2 // indirect ) replace github.com/ory/fosite => github.com/pocket-id/fosite v0.0.0-20260702114848-b55fb7985fde diff --git a/backend/go.sum b/backend/go.sum index 5f3fb24f..bfeec310 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -6,6 +6,8 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e h1:4dAU9FXIyQktpoUAgOJK3OTFc/xug0PCXYCqU0FgDKI= github.com/alexbrainman/sspi v0.0.0-20250919150558-7d374ff0d59e/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4= +github.com/alphadose/haxmap v1.4.1 h1:VtD6VCxUkjNIfJk/aWdYFfOzrRddDFjmvmRmILg7x8Q= +github.com/alphadose/haxmap v1.4.1/go.mod h1:rjHw1IAqbxm0S3U5tD16GoKsiAd8FWx5BJ2IYqXwgmM= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA= @@ -127,6 +129,8 @@ github.com/dsoprea/go-utility v0.0.0-20221003172846-a3e1774ef349/go.mod h1:KVK+/ github.com/dsoprea/go-utility/v2 v2.0.0-20200717064901-2fccff4aa15e/go.mod h1:uAzdkPTub5Y9yQwXe8W4m2XuP0tK4a9Q/dantD0+uaU= github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1 h1:RW22Y3QjGrb97NUA8yupdFcaqg//+hMI2fZrETBvQ4s= github.com/dunglas/go-urlpattern v0.0.0-20241020164140-716dfa1c80b1/go.mod h1:mnVcdqOeYg0HvT6veRo7wINa1mJ+lC/R4ig2lWcapSI= +github.com/dunglas/httpsfv v1.1.0 h1:Jw76nAyKWKZKFrpMMcL76y35tOpYHqQPzHQiwDvpe54= +github.com/dunglas/httpsfv v1.1.0/go.mod h1:zID2mqw9mFsnt7YC3vYQ9/cjq30q41W+1AnDwH8TiMg= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 h1:oP4q0fw+fOSWn3DfFi4EXdT+B+gTtzx8GC9xsc26Znk= @@ -135,8 +139,8 @@ github.com/emersion/go-smtp v0.24.0 h1:g6AfoF140mvW0vLNPD/LuCBLEAdlxOjIXqbIkJIS6 github.com/emersion/go-smtp v0.24.0/go.mod h1:ZtRRkbTyp2XTHCA+BmyTFTrj8xY4I+b4McvHxCU2gsQ= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= @@ -151,10 +155,6 @@ github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= 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/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= -github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= -github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= -github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-co-op/gocron/v2 v2.21.2 h1:bD8/YwkojYHgXFr3iEulL148KBdTbKVxUZzFKpXcdbY= @@ -247,12 +247,20 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/italypaleale/francis v0.1.0-beta.6 h1:88XGnFMwQEMsGSJvI9vrGGvp//WKipXH8YPnfNHDStQ= +github.com/italypaleale/francis v0.1.0-beta.6/go.mod h1:3QQ1iSRGzvxJZ5gfVlVR1RyPDUAgzyN+m2vHeeR7OVs= +github.com/italypaleale/go-kit v0.0.0-20260703002252-b413738a463f h1:cyvaGWNg9AisrfG2guQspCawM6HhLaqbpHAppsePyH4= +github.com/italypaleale/go-kit v0.0.0-20260703002252-b413738a463f/go.mod h1:pl0r3F+thZIyDsyDo8aOUsAIVcsRuAeP1bB4GuAHLoY= +github.com/italypaleale/go-sql-utils v0.2.4-0.20260702021114-e1dfdfb67d0e h1:HDhmMkWAlLy/Elr/+T7diAZgAI+sDY2hYAyIAyefUYk= +github.com/italypaleale/go-sql-utils v0.2.4-0.20260702021114-e1dfdfb67d0e/go.mod h1:BJStxMfB6fzYVcOe0oZQCjGIPZQu76UBmg1Wuy6Z/7I= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6 h1:D/V0gu4zQ3cL2WKeVNVM4r2gLxGGf6McLwgXzRTo2RQ= +github.com/jackc/pgerrcode v0.0.0-20250907135507-afb5586c32a6/go.mod h1:a/s9Lp5W7n/DD0VrVoyJ00FbP2ytTPDVOivvn2bMlds= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx/v5 v5.9.1 h1:uwrxJXBnx76nyISkhr33kQLlUqjv7et7b9FjCen/tdc= -github.com/jackc/pgx/v5 v5.9.1/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jaegertracing/jaeger-idl v0.9.0 h1:dI4olA7ArW3cjXwVbic/aYKDbdlfe7V+9wPQqAdzu8Y= @@ -284,18 +292,18 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= -github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= -github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= -github.com/knadh/koanf/maps v0.1.1 h1:G5TjmUh2D7G2YWf5SQQqSiHRJEjaicvU0KpypqB3NIs= -github.com/knadh/koanf/maps v0.1.1/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= +github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= +github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/json v0.1.0 h1:dzSZl5pf5bBcW0Acnu20Djleto19T0CfHcvZ14NJ6fU= github.com/knadh/koanf/parsers/json v0.1.0/go.mod h1:ll2/MlXcZ2BfXD6YJcjVFzhG9P0TdJ207aIBKQhV2hY= github.com/knadh/koanf/providers/rawbytes v0.1.0 h1:dpzgu2KO6uf6oCb4aP05KDmKmAmI51k5pe8RYKQ0qME= github.com/knadh/koanf/providers/rawbytes v0.1.0/go.mod h1:mMTB1/IcJ/yE++A2iEZbY1MLygX7vttU+C+S/YmPu9c= -github.com/knadh/koanf/v2 v2.1.2 h1:I2rtLRqXRy1p01m/utEtpZSSA6dcJbgGVuE27kW2PzQ= -github.com/knadh/koanf/v2 v2.1.2/go.mod h1:Gphfaen0q1Fc1HTgJgSTC4oRX9R2R5ErYMZJy8fLJBo= +github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= +github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= @@ -320,6 +328,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/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= @@ -396,16 +406,20 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= -github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= -github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/common v0.69.0 h1:OA85nJQS/T/MaYh/Q2CcgDKSGWqNIgrBDvDH85CuiNk= +github.com/prometheus/common v0.69.0/go.mod h1:ZzL3f6u94qUxh9p+tJTrF+FvBS1XXbbRAZCQkytAL0Y= github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= -github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= -github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= +github.com/prometheus/procfs v0.21.1 h1:GljZCt+zSTS+NZq88cyQ1LjZ+RCHp3uVuabBWA5+OJI= +github.com/prometheus/procfs v0.21.1/go.mod h1:aB55Cww9pdSJVHk0hUf0inxWyyjPogFIjmHKYgMKmtY= +github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0= +github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= -github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= -github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0= +github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk= +github.com/quic-go/webtransport-go v0.10.0 h1:LqXXPOXuETY5Xe8ITdGisBzTYmUOy5eSj+9n4hLTjHI= +github.com/quic-go/webtransport-go v0.10.0/go.mod h1:LeGIXr5BQKE3UsynwVBeQrU1TPrbh73MGoC6jd+V7ow= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= @@ -435,8 +449,9 @@ github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjb github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= -github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= +github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -465,6 +480,10 @@ github.com/urfave/negroni v1.0.0 h1:kIimOitoypq34K7TG7DUaJ9kq/N4Ofuwi1sjz0KipXc= github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4= github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE= +github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= +github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= +github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= +github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= @@ -561,8 +580,8 @@ golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v 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/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM= -golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80= +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.42.0 h1:1gSs6ehNWXLbkHBIPcWztk3D/6aIA/8hauiAYtlodVY= golang.org/x/image v0.42.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= @@ -595,8 +614,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= -golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= -golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= @@ -661,8 +680,8 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.8.0/go.mod h1:JxBZ99ISMI5ViVkT1tr6tdNmXeTrcpVSD3vZ1RsRdN4= golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= -golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= -golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= +golang.org/x/tools v0.46.0 h1:7jTurBkPZu4moS/Uy4OQT1M+QBlsj3wejyZwsT8Z7rk= +golang.org/x/tools v0.46.0/go.mod h1:FrD85F8l+NWL+9XWBSyVSHO6Ne4jutsfIFba7AWQ5Ys= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -670,12 +689,12 @@ golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8T gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab h1:Foefixyu0l973HSYkX8Etw/fPxAmKRhyMGwuqXFiVI0= -google.golang.org/genproto/googleapis/api v0.0.0-20260608224507-4308a22a1bab/go.mod h1:KdNqO+rCIWgFumrNBSEDlDNrkrQnpkax7Tv1WxNY8V4= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab h1:cY0oV1VnAqvaim8VsR8ZyEKAudzbRJMRGwD3W/L7yOw= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260608224507-4308a22a1bab/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7 h1:jQ9p21COKWjP3VwuFrNRiiOTMh3mPpN45R7SLrH/HUU= +google.golang.org/genproto/googleapis/api v0.0.0-20260630182238-925bb5da69e7/go.mod h1:KqHwBx2upmfa1XSi1WuRvC+2VGCLtooKkfmyvRbUmqA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7 h1:eM/YSd5bBFagF51o1E745Ta7RwzpW0h+z+QDNZOgmQ8= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260630182238-925bb5da69e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.82.0 h1:vguDnZUPjE26w09A63VoxZPnvPjB5Riyc0mkXPFmAIU= +google.golang.org/grpc v1.82.0/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -692,30 +711,32 @@ gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4= gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo= gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= -modernc.org/cc/v4 v4.27.3 h1:uNCgn37E5U09mTv1XgskEVUJ8ADKpmFMPxzGJ0TSo+U= -modernc.org/cc/v4 v4.27.3/go.mod h1:3YjcbCqhoTTHPycJDRl2WZKKFj0nwcOIPBfEZK0Hdk8= -modernc.org/ccgo/v4 v4.32.4 h1:L5OB8rpEX4ZsXEQwGozRfJyJSFHbbNVOoQ59DU9/KuU= -modernc.org/ccgo/v4 v4.32.4/go.mod h1:lY7f+fiTDHfcv6YlRgSkxYfhs+UvOEEzj49jAn2TOx0= +k8s.io/utils v0.0.0-20260617174310-a95e086a2553 h1:hmGqDecjc8d7HVzWzRFl0QD9bYuYKbBEG7t8xwnVxfI= +k8s.io/utils v0.0.0-20260617174310-a95e086a2553/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= +modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= +modernc.org/ccgo/v4 v4.34.5 h1:hcwnthv2/LBl+mRLOYwnQA/LuW44Oln1NQlWppNaS1Q= +modernc.org/ccgo/v4 v4.34.5/go.mod h1:aow0HNkO30OSA/2NrtDXkis92ff8ZFiDOmDOPhqhF8U= modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= -modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= -modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/gc/v3 v3.1.4 h1:2g65LGVSmFQrXeITAw97x7hCRvZFcyE1uDP+7Vng7JI= +modernc.org/gc/v3 v3.1.4/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.71.0 h1:bu0djXJGhqed3DnBzyzu3sY0fv432lesyz99ecEahyA= -modernc.org/libc v1.71.0/go.mod h1:tTU8DL8A+XLVkEY3x5E/tO7s2Q/q42EtnNWda/L5QhQ= +modernc.org/libc v1.73.5 h1:G34rN/cRqL+zOUnrbz9uPq/+OxJ8/vzQ2CQwTJ42Wmw= +modernc.org/libc v1.73.5/go.mod h1:+Aoyx4M0etg6GikzCrip1VtvAtUlMlo2Aq+GHwQSqOA= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= -modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= -modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg= +modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.48.2 h1:5CnW4uP8joZtA0LedVqLbZV5GD7F/0x91AXeSyjoh5c= -modernc.org/sqlite v1.48.2/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M= +modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/backend/internal/bootstrap/actors_bootstrap.go b/backend/internal/bootstrap/actors_bootstrap.go new file mode 100644 index 00000000..9ffa1485 --- /dev/null +++ b/backend/internal/bootstrap/actors_bootstrap.go @@ -0,0 +1,162 @@ +package bootstrap + +import ( + "database/sql" + "errors" + "fmt" + "log/slog" + "net" + "net/http" + "time" + + "github.com/italypaleale/francis/builtin/ratelimit" + "github.com/italypaleale/francis/components/postgres" + "github.com/italypaleale/francis/host/local" + "github.com/jackc/pgx/v5/pgxpool" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/job" + "github.com/pocket-id/pocket-id/backend/internal/middleware" + "github.com/pocket-id/pocket-id/backend/internal/service" + "github.com/pocket-id/pocket-id/backend/internal/storage" + "github.com/pocket-id/pocket-id/backend/internal/utils/crypto" +) + +type NewActorsOpts struct { + SQLite *sql.DB + Postgres *pgxpool.Pool + + EnvConfig *common.EnvConfigSchema + AppConfig *service.AppConfigService + HttpClient *http.Client + DB *gorm.DB + FileStorage storage.FileStorage +} + +func NewActors(o NewActorsOpts) (*local.Host, map[string]*ratelimit.RateLimitService, error) { + log := slog.Default() + + // Derive a PSK from the global encryption key + // The runtime PSK derives the cluster CA used for host-to-host mTLS + psk, err := o.getPSK() + if err != nil { + return nil, nil, fmt.Errorf("failed to derive PSK: %w", err) + } + + // Options for the host + opts := []local.HostOption{ + local.WithAddress(net.JoinHostPort(o.EnvConfig.ActorsHost, o.EnvConfig.ActorsPort)), + local.WithLogger(log.With("scope", "actor-host")), + local.WithRuntimePSKs(psk), + local.WithShutdownGracePeriod(10 * time.Second), + } + + // Add all cron jobs + cronjobs, err := o.getCronJobs() + if err != nil { + return nil, nil, err + } + opts = append(opts, cronjobs...) + + // Add the rate limiters + rateLimiters, rateLimiterOpts, err := o.getRateLimiters() + if err != nil { + return nil, nil, err + } + opts = append(opts, rateLimiterOpts...) + + // Add the database connection + providerOpt, err := o.getProvider() + if err != nil { + return nil, nil, err + } + opts = append(opts, providerOpt) + + // Create a new actor host + h, err := local.NewHost(opts...) + if err != nil { + return nil, nil, fmt.Errorf("failed to create actor host: %w", err) + } + + // Bind a service for each rate limiter so the middleware can invoke them + rateLimitServices := make(map[string]*ratelimit.RateLimitService, len(rateLimiters)) + for name, rl := range rateLimiters { + rateLimitServices[name] = rl.Service(h.Service()) + } + + return h, rateLimitServices, nil +} + +// Derive a PSK from the global encryption key +func (o *NewActorsOpts) getPSK() ([]byte, error) { + // Note: changing the key derivation or the seed is a breaking change + return crypto.DeriveKey(o.EnvConfig.EncryptionKey, "pocketid/actors-psk") +} + +func (o *NewActorsOpts) getProvider() (local.HostOption, error) { + switch { + case o.Postgres != nil && o.SQLite != nil: + return nil, errors.New("cannot have both Postgres and SQLite connections") + case o.Postgres != nil: + return local.WithPostgresProvider(postgres.PostgresProviderOptions{ + DB: o.Postgres, + }), nil + case o.SQLite != nil: + return local.WithSQLiteProvider(local.SQLiteProviderOptions{ + DB: o.SQLite, + }), nil + default: + return nil, errors.New("one of Postgres and SQLite must be set") + } +} + +func (o *NewActorsOpts) getCronJobs() (opts []local.HostOption, err error) { + // In test mode, we do not register anything + if common.EnvConfig.AppEnv == "test" { + return opts, nil + } + + // Register the analytics job + analyticsJob, err := job.GetAnalyticsJob(o.AppConfig, o.HttpClient) + if err != nil { + return nil, fmt.Errorf("failed to get analytics cron job: %w", err) + } + if analyticsJob != nil { + // This could be nil if analytics are disabled + opts = append(opts, local.WithBuiltInActor(analyticsJob)) + } + + // Register the file cleanup jobs + fileCleanupJobs, err := job.GetFileCleanupJobs(o.DB, o.FileStorage) + if err != nil { + return nil, fmt.Errorf("failed to get file cleanup cron jobs: %w", err) + } + for _, j := range fileCleanupJobs { + opts = append(opts, local.WithBuiltInActor(j)) + } + + return opts, nil +} + +// getRateLimiters creates a built-in rate-limit actor for each middleware policy and returns both the created actors (keyed by policy name) and the host options to register them +// Unlike cron jobs, rate limiters keep no durable state, so they are registered in every environment +func (o *NewActorsOpts) getRateLimiters() (actors map[string]*ratelimit.RateLimit, opts []local.HostOption, err error) { + policies := middleware.RateLimitPolicies() + actors = make(map[string]*ratelimit.RateLimit, len(policies)) + for _, p := range policies { + rl, err := ratelimit.New( + p.Name, + ratelimit.WithRate(p.Rate), + ratelimit.WithPer(p.Per), + ratelimit.WithBurst(p.Burst), + ) + if err != nil { + return nil, nil, fmt.Errorf("error creating rate limiter %q: %w", p.Name, err) + } + actors[p.Name] = rl + opts = append(opts, local.WithBuiltInActor(rl)) + } + + return actors, opts, nil +} diff --git a/backend/internal/bootstrap/actors_bootstrap_test.go b/backend/internal/bootstrap/actors_bootstrap_test.go new file mode 100644 index 00000000..6a07d45e --- /dev/null +++ b/backend/internal/bootstrap/actors_bootstrap_test.go @@ -0,0 +1,26 @@ +package bootstrap + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" +) + +func TestNewActorsOptsGetPSKUsesStableValue(t *testing.T) { + opts := NewActorsOpts{ + EnvConfig: &common.EnvConfigSchema{ + EncryptionKey: []byte("test-encryption-key"), + }, + } + + expectedHex := "651300d35d48998d0fa66ac89091bcde8ed0fd0aa35fbb849f068410c64807e9" + expected, err := hex.DecodeString(expectedHex) + require.NoError(t, err) + + actual, err := opts.getPSK() + require.NoError(t, err) + require.Equal(t, expected, actual) +} diff --git a/backend/internal/bootstrap/bootstrap.go b/backend/internal/bootstrap/bootstrap.go index 692e6b0b..461e8848 100644 --- a/backend/internal/bootstrap/bootstrap.go +++ b/backend/internal/bootstrap/bootstrap.go @@ -8,23 +8,32 @@ import ( "time" _ "github.com/golang-migrate/migrate/v4/source/file" + + "github.com/italypaleale/go-kit/servicerunner" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/job" "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" - "github.com/pocket-id/pocket-id/backend/internal/utils" ) func Bootstrap(ctx context.Context) error { - var shutdownFns []utils.Service + var ( + shutdownFns []servicerunner.Service + closeDatabasePool func() + ) defer func() { //nolint:contextcheck // Invoke all shutdown functions on exit - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + shutdownCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second) defer cancel() - if err := utils.NewServiceRunner(shutdownFns...).Run(shutdownCtx); err != nil { - slog.Error("Error during graceful shutdown", "error", err) + shutdownErr := servicerunner.NewServiceRunner(shutdownFns...).Run(shutdownCtx) + if shutdownErr != nil { + slog.Error("Error during graceful shutdown", "error", shutdownErr) + } + // Close the database connection pool only after the shutdown functions have run: some of them (e.g. releasing the application lock) still need to query the database. + if closeDatabasePool != nil { + closeDatabasePool() } }() @@ -33,23 +42,31 @@ func Bootstrap(ctx context.Context) error { if err != nil { return fmt.Errorf("failed to initialize OpenTelemetry: %w", err) } + slog.InfoContext(ctx, "Pocket ID is starting") - db, err := NewDatabase() + // Init database + db, pg, err := NewDatabase(ctx) if err != nil { return fmt.Errorf("failed to initialize database: %w", err) } + if pg != nil { + closeDatabasePool = pg.Close + } + // Init storage fileStorage, err := InitStorage(ctx, db) if err != nil { return fmt.Errorf("failed to initialize file storage (backend: %s): %w", common.EnvConfig.FileBackend, err) } + // Init application images imageExtensions, err := initApplicationImages(ctx, fileStorage) if err != nil { return fmt.Errorf("failed to initialize application images: %w", err) } + // Init the scheduler scheduler, err := job.NewScheduler() if err != nil { return fmt.Errorf("failed to create job scheduler: %w", err) @@ -61,6 +78,7 @@ func Bootstrap(ctx context.Context) error { return fmt.Errorf("failed to initialize services: %w", err) } + // Acquire the lock from the app lock service waitUntil, err := svc.appLockService.Acquire(ctx, false) if errors.Is(err, service.ErrLockUnavailable) { return errors.New("it appears that there's already one instance of Pocket ID running; running multiple replicas of Pocket ID is currently not supported") @@ -83,27 +101,54 @@ func Bootstrap(ctx context.Context) error { } shutdownFns = append(shutdownFns, shutdownFn) + // Init the actors + actorsOpts := NewActorsOpts{ + Postgres: pg, + + EnvConfig: &common.EnvConfig, + AppConfig: svc.appConfigService, + HttpClient: httpClient, + DB: db, + FileStorage: fileStorage, + } + if pg == nil { + actorsOpts.SQLite, err = db.DB() + if err != nil { + return fmt.Errorf("failed to get *sql.DB connection from Gorm: %w", err) + } + } + actors, rateLimitServices, err := NewActors(actorsOpts) + if err != nil { + return fmt.Errorf("failed to initialize actors: %w", err) + } + // Register scheduled jobs - err = registerScheduledJobs(ctx, db, svc, httpClient, scheduler) + err = registerScheduledJobs(ctx, db, svc, scheduler) if err != nil { return fmt.Errorf("failed to register scheduled jobs: %w", err) } // Init the router - router, err := initRouter(db, svc) + // The rate-limit middleware invokes the actor host with each request's own context, so the setup context is intentionally not threaded through the router + //nolint:contextcheck + router, err := initRouter(db, svc, rateLimitServices) if err != nil { return fmt.Errorf("failed to initialize router: %w", err) } // Run all background services // This call blocks until the context is canceled - services := []utils.Service{svc.appLockService.RunRenewal, router} + services := []servicerunner.Service{ + svc.appLockService.RunRenewal, + actors.Run, + router, + } if common.EnvConfig.AppEnv != "test" { services = append(services, scheduler.Run) } - err = utils.NewServiceRunner(services...).Run(ctx) + err = servicerunner.NewServiceRunner(services...).Run(ctx) if err != nil { return fmt.Errorf("failed to run services: %w", err) } diff --git a/backend/internal/bootstrap/db_bootstrap.go b/backend/internal/bootstrap/db_bootstrap.go index 291d3961..85e5ce5e 100644 --- a/backend/internal/bootstrap/db_bootstrap.go +++ b/backend/internal/bootstrap/db_bootstrap.go @@ -1,6 +1,7 @@ package bootstrap import ( + "context" "database/sql" "errors" "fmt" @@ -11,8 +12,10 @@ import ( "strings" "time" - "github.com/glebarez/sqlite" _ "github.com/golang-migrate/migrate/v4/source/github" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" + "github.com/libtnb/sqlite" slogGorm "github.com/orandin/slog-gorm" "gorm.io/driver/postgres" "gorm.io/gorm" @@ -23,51 +26,51 @@ import ( sqliteutil "github.com/pocket-id/pocket-id/backend/internal/utils/sqlite" ) -func NewDatabase() (db *gorm.DB, err error) { - db, err = ConnectDatabase() +func NewDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) { + db, pg, err = ConnectDatabase(ctx) if err != nil { - return nil, fmt.Errorf("failed to connect to database: %w", err) + return nil, nil, fmt.Errorf("failed to connect to database: %w", err) } sqlDb, err := db.DB() if err != nil { - return nil, fmt.Errorf("failed to get sql.DB: %w", err) + return nil, nil, fmt.Errorf("failed to get sql.DB: %w", err) } // Run migrations - err = utils.MigrateDatabase(sqlDb) + err = utils.MigrateDatabase(ctx, sqlDb) if err != nil { - return nil, fmt.Errorf("failed to run migrations: %w", err) + return nil, nil, fmt.Errorf("failed to run migrations: %w", err) } - return db, nil + return db, pg, nil } -func ConnectDatabase() (db *gorm.DB, err error) { - var ( - dialector gorm.Dialector - sqliteNetworkFilesystem bool - ) +//nolint:gocognit +func ConnectDatabase(ctx context.Context) (db *gorm.DB, pg *pgxpool.Pool, err error) { + var dialector gorm.Dialector // Choose the correct database provider var onConnFn func(conn *sql.DB) switch common.EnvConfig.DbProvider { case common.DbProviderSqlite: if common.EnvConfig.DbConnectionString == "" { - return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database") + return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for SQLite database") } sqliteutil.RegisterSqliteFunctions() connString, dbPath, isMemoryDB, err := parseSqliteConnectionString(common.EnvConfig.DbConnectionString) if err != nil { - return nil, err + return nil, nil, err } if !isMemoryDB { - if err := ensureSqliteDatabaseDir(dbPath); err != nil { - return nil, err + err = ensureSqliteDatabaseDir(dbPath) + if err != nil { + return nil, nil, err } + var sqliteNetworkFilesystem bool sqliteNetworkFilesystem, err = utils.IsNetworkedFileSystem(filepath.Dir(dbPath)) if err != nil { // Log the error only @@ -80,7 +83,7 @@ func ConnectDatabase() (db *gorm.DB, err error) { // 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 + return nil, nil, err } if isMemoryDB { @@ -94,13 +97,33 @@ func ConnectDatabase() (db *gorm.DB, err error) { dialector = sqlite.Open(connString) case common.DbProviderPostgres: if common.EnvConfig.DbConnectionString == "" { - return nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") + return nil, nil, errors.New("missing required env var 'DB_CONNECTION_STRING' for Postgres database") } - dialector = postgres.Open(common.EnvConfig.DbConnectionString) + + // We need a pgxpool object for francis, so we open this as a pgxpool... + pg, err = pgxpool.New(ctx, common.EnvConfig.DbConnectionString) + if err != nil { + return nil, nil, fmt.Errorf("failed to create Postgres pool: %w", err) + } + + // ...test it with a ping... + pingCtx, pingCancel := context.WithTimeout(ctx, 10*time.Second) + defer pingCancel() + err = pg.Ping(pingCtx) + if err != nil { + pg.Close() + return nil, nil, fmt.Errorf("failed to ping Postgres database: %w", err) + } + + // ...then create the dialector by adapting it to *sql.DB + dialector = postgres.New(postgres.Config{ + Conn: stdlib.OpenDBFromPool(pg), + }) default: - return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) + return nil, nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) } + // Try connecting up to 3 times for i := 1; i <= 3; i++ { db, err = gorm.Open(dialector, &gorm.Config{ TranslateError: true, @@ -109,25 +132,34 @@ func ConnectDatabase() (db *gorm.DB, err error) { if err == nil { slog.Info("Connected to database", slog.String("provider", string(common.EnvConfig.DbProvider))) + // Invoke the onConnFn callback if any if onConnFn != nil { conn, err := db.DB() if err != nil { - slog.Warn("Failed to get database connection, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err)) - time.Sleep(3 * time.Second) + if pg != nil { + pg.Close() + } + return nil, nil, fmt.Errorf("failed to get database connection for onConnFn callback: %w", err) } + onConnFn(conn) } - return db, nil + return db, pg, nil } + // If we're here, the connection failed slog.Warn("Failed to connect to database, will retry in 3s", slog.Int("attempt", i), slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err)) time.Sleep(3 * time.Second) } slog.Error("Failed to connect to database after 3 attempts", slog.String("provider", string(common.EnvConfig.DbProvider)), slog.Any("error", err)) - return nil, err + if pg != nil { + pg.Close() + } + + return nil, nil, err } func parseSqliteConnectionString(connString string) (parsedConnString string, dbPath string, isMemoryDB bool, err error) { diff --git a/backend/internal/bootstrap/observability_boostrap.go b/backend/internal/bootstrap/observability_boostrap.go index 6c89861e..8331928b 100644 --- a/backend/internal/bootstrap/observability_boostrap.go +++ b/backend/internal/bootstrap/observability_boostrap.go @@ -9,7 +9,7 @@ import ( "time" sloggin "github.com/gin-contrib/slog" - + "github.com/italypaleale/go-kit/servicerunner" "github.com/lmittmann/tint" "github.com/mattn/go-isatty" "go.opentelemetry.io/contrib/bridges/otelslog" @@ -27,7 +27,6 @@ import ( tracenoop "go.opentelemetry.io/otel/trace/noop" "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/pocket-id/pocket-id/backend/internal/utils" ) func defaultResource() (*resource.Resource, error) { @@ -40,13 +39,13 @@ func defaultResource() (*resource.Resource, error) { ) } -func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []utils.Service, httpClient *http.Client, err error) { +func initObservability(ctx context.Context, metrics, traces bool) (shutdownFns []servicerunner.Service, httpClient *http.Client, err error) { resource, err := defaultResource() if err != nil { return nil, nil, fmt.Errorf("failed to create OpenTelemetry resource: %w", err) } - shutdownFns = make([]utils.Service, 0, 2) + shutdownFns = make([]servicerunner.Service, 0, 2) httpClient = &http.Client{} defaultTransport, ok := http.DefaultTransport.(*http.Transport) @@ -132,7 +131,7 @@ func initOtelLogging(ctx context.Context, resource *resource.Resource) error { return nil } -func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn utils.Service, err error) { +func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resource, httpClient *http.Client) (shutdownFn servicerunner.Service, err error) { if !traces { otel.SetTracerProvider(tracenoop.NewTracerProvider()) return nil, nil @@ -171,7 +170,7 @@ func initOtelTracing(ctx context.Context, traces bool, resource *resource.Resour return shutdownFn, nil } -func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn utils.Service, err error) { +func initOtelMetrics(ctx context.Context, metrics bool, resource *resource.Resource) (shutdownFn servicerunner.Service, err error) { if !metrics { otel.SetMeterProvider(metricnoop.NewMeterProvider()) return nil, nil diff --git a/backend/internal/bootstrap/router_bootstrap.go b/backend/internal/bootstrap/router_bootstrap.go index 2490e854..b8b08e81 100644 --- a/backend/internal/bootstrap/router_bootstrap.go +++ b/backend/internal/bootstrap/router_bootstrap.go @@ -17,27 +17,27 @@ import ( "github.com/fsnotify/fsnotify" sloggin "github.com/gin-contrib/slog" "github.com/gin-gonic/gin" + "github.com/italypaleale/francis/builtin/ratelimit" + "github.com/italypaleale/go-kit/servicerunner" "go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin" - "golang.org/x/time/rate" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/frontend" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/controller" "github.com/pocket-id/pocket-id/backend/internal/middleware" - "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/utils/systemd" ) // This is used to register additional controllers for tests var registerTestControllers []func(apiGroup *gin.RouterGroup, db *gorm.DB, svc *services) -func initRouter(db *gorm.DB, svc *services) (utils.Service, error) { +func initRouter(db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) (servicerunner.Service, error) { r, err := initEngine() if err != nil { return nil, err } - err = registerRoutes(r, db, svc) + err = registerRoutes(r, db, svc, rateLimitServices) if err != nil { return nil, err } @@ -107,7 +107,7 @@ func registerGlobalMiddleware(r *gin.Engine) { r.Use(middleware.NewErrorHandlerMiddleware().Add()) } -func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { +func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services, rateLimitServices map[string]*ratelimit.RateLimitService) error { err := frontend.RegisterFrontend(r) if errors.Is(err, frontend.ErrFrontendNotIncluded) { @@ -119,7 +119,8 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { // Initialize middleware for specific routes authMiddleware := middleware.NewAuthMiddleware(svc.apiKeyModule, svc.userService, svc.jwtService) fileSizeLimitMiddleware := middleware.NewFileSizeLimitMiddleware() - apiRateLimitMiddleware := middleware.NewRateLimitMiddleware().Add(rate.Every(time.Second), 100) + rateLimitMiddleware := middleware.NewRateLimitMiddleware(rateLimitServices) + apiRateLimitMiddleware := rateLimitMiddleware.Add(middleware.RateLimitAPI) apiGroup := r.Group("/api", apiRateLimitMiddleware) baseGroup := r.Group("/", apiRateLimitMiddleware) @@ -128,14 +129,13 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { authMiddleware.WithAdminNotRequired().Add(), authMiddleware.WithAdminNotRequired().WithApiKeyAuthDisabled().Add(), ) - webauthnRateLimitMiddleware := middleware.NewRateLimitMiddleware() svc.webauthnModule.RegisterRoutes(apiGroup, authMiddleware.WithAdminNotRequired().Add(), - webauthnRateLimitMiddleware.Add(rate.Every(10*time.Second), 5), - webauthnRateLimitMiddleware.Add(rate.Every(10*time.Second), 5), + rateLimitMiddleware.Add(middleware.RateLimitWebauthnLogin), + rateLimitMiddleware.Add(middleware.RateLimitWebauthnReauthenticate), ) controller.NewOidcController(apiGroup, authMiddleware, fileSizeLimitMiddleware, svc.oidcService) - controller.NewUserController(apiGroup, authMiddleware, middleware.NewRateLimitMiddleware(), svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) + controller.NewUserController(apiGroup, authMiddleware, rateLimitMiddleware, svc.userService, svc.oneTimeAccessService, svc.webauthnModule, svc.appConfigService) controller.NewAppConfigController(apiGroup, authMiddleware, svc.appConfigService, svc.emailService, svc.ldapService) controller.NewAppImagesController(apiGroup, authMiddleware, svc.appImagesService) controller.NewAuditLogController(apiGroup, svc.auditLogService, authMiddleware) @@ -145,7 +145,7 @@ func registerRoutes(r *gin.Engine, db *gorm.DB, svc *services) error { controller.NewScimController(apiGroup, authMiddleware, svc.scimService) svc.userSignUpModule.RegisterRoutes(apiGroup, authMiddleware.Add(), - middleware.NewRateLimitMiddleware().Add(rate.Every(1*time.Minute), 10), + rateLimitMiddleware.Add(middleware.RateLimitSignup), ) optionalBrowserAuth := authMiddleware.WithAdminNotRequired().WithSuccessOptional().WithApiKeyAuthDisabled().Add() diff --git a/backend/internal/bootstrap/scheduler_bootstrap.go b/backend/internal/bootstrap/scheduler_bootstrap.go index a4d343fa..c31f60e2 100644 --- a/backend/internal/bootstrap/scheduler_bootstrap.go +++ b/backend/internal/bootstrap/scheduler_bootstrap.go @@ -3,14 +3,13 @@ package bootstrap import ( "context" "fmt" - "net/http" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/job" ) -func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, httpClient *http.Client, scheduler *job.Scheduler) error { +func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, scheduler *job.Scheduler) error { err := scheduler.RegisterLdapJobs(ctx, svc.ldapService, svc.appConfigService) if err != nil { return fmt.Errorf("failed to register LDAP jobs in scheduler: %w", err) @@ -23,18 +22,10 @@ func registerScheduledJobs(ctx context.Context, db *gorm.DB, svc *services, http if err != nil { return fmt.Errorf("failed to register DB cleanup jobs in scheduler: %w", err) } - err = scheduler.RegisterFileCleanupJobs(ctx, db, svc.fileStorage) - if err != nil { - return fmt.Errorf("failed to register file cleanup jobs in scheduler: %w", err) - } err = scheduler.RegisterApiKeyExpiryJob(ctx, svc.apiKeyModule, svc.appConfigService, svc.emailService) if err != nil { return fmt.Errorf("failed to register API key expiration jobs in scheduler: %w", err) } - err = scheduler.RegisterAnalyticsJob(ctx, svc.appConfigService, httpClient) - if err != nil { - return fmt.Errorf("failed to register analytics job in scheduler: %w", err) - } err = scheduler.RegisterScimJobs(ctx, svc.scimService) if err != nil { return fmt.Errorf("failed to register SCIM scheduler job: %w", err) diff --git a/backend/internal/bootstrap/services_bootstrap.go b/backend/internal/bootstrap/services_bootstrap.go index 4a850f84..dce5ab38 100644 --- a/backend/internal/bootstrap/services_bootstrap.go +++ b/backend/internal/bootstrap/services_bootstrap.go @@ -86,7 +86,7 @@ func initServices(ctx context.Context, db *gorm.DB, httpClient *http.Client, ima Config: oidc.Config{ BaseURL: common.EnvConfig.AppURL, TokenBaseURL: common.EnvConfig.AppURL, - Secret: string(common.EnvConfig.EncryptionKey), + Secret: common.EnvConfig.EncryptionKey, }, Signer: svc.jwtService, CustomClaims: svc.customClaimService, diff --git a/backend/internal/cmds/encryption_key_rotate.go b/backend/internal/cmds/encryption_key_rotate.go index 7b5ea98d..ab51f944 100644 --- a/backend/internal/cmds/encryption_key_rotate.go +++ b/backend/internal/cmds/encryption_key_rotate.go @@ -30,7 +30,7 @@ func init() { Use: "encryption-key-rotate", Short: "Re-encrypts data using a new encryption key", RunE: func(cmd *cobra.Command, args []string) error { - db, err := bootstrap.NewDatabase() + db, _, err := bootstrap.NewDatabase(cmd.Context()) if err != nil { return err } diff --git a/backend/internal/cmds/export.go b/backend/internal/cmds/export.go index 55194e2f..880670a0 100644 --- a/backend/internal/cmds/export.go +++ b/backend/internal/cmds/export.go @@ -33,7 +33,7 @@ func init() { // runExport orchestrates the export flow func runExport(ctx context.Context, flags exportFlags) error { - db, err := bootstrap.NewDatabase() + db, _, err := bootstrap.NewDatabase(ctx) if err != nil { return fmt.Errorf("failed to connect to database: %w", err) } diff --git a/backend/internal/cmds/import.go b/backend/internal/cmds/import.go index 0f51c492..3a13d9e4 100644 --- a/backend/internal/cmds/import.go +++ b/backend/internal/cmds/import.go @@ -73,7 +73,7 @@ func runImport(ctx context.Context, flags importFlags) error { } defer zipReader.Close() - db, err := bootstrap.ConnectDatabase() + db, _, err := bootstrap.ConnectDatabase(ctx) if err != nil { return err } diff --git a/backend/internal/cmds/key_rotate.go b/backend/internal/cmds/key_rotate.go index 34a1b26e..cd1c652e 100644 --- a/backend/internal/cmds/key_rotate.go +++ b/backend/internal/cmds/key_rotate.go @@ -31,7 +31,7 @@ func init() { Use: "key-rotate", Short: "Generates a new token signing key and replaces the current one", RunE: func(cmd *cobra.Command, args []string) error { - db, err := bootstrap.NewDatabase() + db, _, err := bootstrap.NewDatabase(cmd.Context()) if err != nil { return err } diff --git a/backend/internal/cmds/one_time_access_token.go b/backend/internal/cmds/one_time_access_token.go index 7aa5b56b..dd3f392c 100644 --- a/backend/internal/cmds/one_time_access_token.go +++ b/backend/internal/cmds/one_time_access_token.go @@ -24,7 +24,7 @@ var oneTimeAccessTokenCmd = &cobra.Command{ userArg := args[0] // Connect to the database - db, err := bootstrap.NewDatabase() + db, _, err := bootstrap.NewDatabase(cmd.Context()) if err != nil { return err } diff --git a/backend/internal/cmds/root.go b/backend/internal/cmds/root.go index 01391ec2..98a858a7 100644 --- a/backend/internal/cmds/root.go +++ b/backend/internal/cmds/root.go @@ -5,10 +5,10 @@ import ( "log/slog" "os" + "github.com/italypaleale/go-kit/signals" "github.com/spf13/cobra" "github.com/pocket-id/pocket-id/backend/internal/bootstrap" - "github.com/pocket-id/pocket-id/backend/internal/utils/signals" ) var rootCmd = &cobra.Command{ diff --git a/backend/internal/common/env_config.go b/backend/internal/common/env_config.go index fa4f3f0c..06e17b3d 100644 --- a/backend/internal/common/env_config.go +++ b/backend/internal/common/env_config.go @@ -78,6 +78,9 @@ type EnvConfigSchema struct { GeoLiteDBPath string `env:"GEOLITE_DB_PATH"` GeoLiteDBUrl string `env:"GEOLITE_DB_URL"` + ActorsPort string `env:"ACTORS_PORT"` + ActorsHost string `env:"ACTORS_HOST" options:"toLower"` + LogLevel string `env:"LOG_LEVEL" options:"toLower"` MetricsEnabled bool `env:"METRICS_ENABLED"` TracingEnabled bool `env:"TRACING_ENABLED"` @@ -104,6 +107,8 @@ func defaultConfig() EnvConfigSchema { AppURL: AppUrl, Port: "1411", Host: "0.0.0.0", + ActorsPort: "1414", + ActorsHost: "0.0.0.0", GeoLiteDBPath: "data/GeoLite2-City.mmdb", GeoLiteDBUrl: MaxMindGeoLiteCityUrl, } @@ -138,39 +143,53 @@ func ValidateEnvConfig(config *EnvConfigSchema) error { return nil } - if _, err := sloggin.ParseLevel(config.LogLevel); err != nil { + _, err := sloggin.ParseLevel(config.LogLevel) + if err != nil { return errors.New("invalid LOG_LEVEL value. Must be 'debug', 'info', 'warn' or 'error'") } + // Check required properties if len(config.EncryptionKey) < 16 { return errors.New("ENCRYPTION_KEY must be at least 16 bytes long") } - prepareDbConfig(config) - - if err := validateAppURLs(config); err != nil { - return err - } - if err := validateFileBackend(config); err != nil { - return err - } if config.SystemdSocket && config.UnixSocket != "" { return errors.New("SYSTEMD_SOCKET and UNIX_SOCKET are mutually exclusive") } - if err := validateLocalIPv6Ranges(config.LocalIPv6Ranges); err != nil { - return err - } if config.AuditLogRetentionDays <= 0 { return errors.New("AUDIT_LOG_RETENTION_DAYS must be greater than 0") } if config.StaticApiKey != "" && len(config.StaticApiKey) < 16 { - return errors.New("STATIC_API_KEY must be at least 16 characters long") + return errors.New("when set, STATIC_API_KEY must be at least 16 characters long") } - return validateTLSConfig(config) + // Prepare the DB config + prepareDbConfig(config) + // Validate other required options + err = validateAppURLs(config) + if err != nil { + return err + } + + err = validateFileBackend(config) + if err != nil { + return err + } + + err = validateLocalIPv6Ranges(config.LocalIPv6Ranges) + if err != nil { + return err + } + + err = validateTLSConfig(config) + if err != nil { + return err + } + + return nil } func prepareDbConfig(config *EnvConfigSchema) { diff --git a/backend/internal/common/env_config_test.go b/backend/internal/common/env_config_test.go index a5cbb309..0eec5424 100644 --- a/backend/internal/common/env_config_test.go +++ b/backend/internal/common/env_config_test.go @@ -175,6 +175,8 @@ func TestParseEnvConfig(t *testing.T) { t.Setenv("UNIX_SOCKET", "/tmp/app.sock") t.Setenv("MAXMIND_LICENSE_KEY", "test-license") t.Setenv("GEOLITE_DB_PATH", "/custom/geolite.mmdb") + t.Setenv("ACTORS_PORT", "9999") + t.Setenv("ACTORS_HOST", "LOCALHOST") err := parseAndValidateEnvConfig(t) require.NoError(t, err) @@ -182,6 +184,8 @@ func TestParseEnvConfig(t *testing.T) { assert.Equal(t, "/custom/uploads", EnvConfig.UploadPath) assert.Equal(t, "8080", EnvConfig.Port) assert.Equal(t, "localhost", EnvConfig.Host) // lowercased + assert.Equal(t, "9999", EnvConfig.ActorsPort) + assert.Equal(t, "localhost", EnvConfig.ActorsHost) // lowercased }) t.Run("should normalize file backend and default upload path", func(t *testing.T) { diff --git a/backend/internal/controller/user_controller.go b/backend/internal/controller/user_controller.go index 5f372638..615aa21e 100644 --- a/backend/internal/controller/user_controller.go +++ b/backend/internal/controller/user_controller.go @@ -13,7 +13,6 @@ import ( "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/utils" "github.com/pocket-id/pocket-id/backend/internal/webauthn" - "golang.org/x/time/rate" ) const defaultOneTimeAccessTokenDuration = 15 * time.Minute @@ -51,14 +50,14 @@ func NewUserController(group *gin.RouterGroup, authMiddleware *middleware.AuthMi group.POST("/users/me/one-time-access-token", authMiddleware.WithAdminNotRequired().Add(), uc.createOwnOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-token", authMiddleware.Add(), uc.createAdminOneTimeAccessTokenHandler) group.POST("/users/:id/one-time-access-email", authMiddleware.Add(), uc.RequestOneTimeAccessEmailAsAdminHandler) - group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), uc.exchangeOneTimeAccessTokenHandler) - group.POST("/one-time-access-email", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler) + group.POST("/one-time-access-token/:token", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessToken), uc.exchangeOneTimeAccessTokenHandler) + group.POST("/one-time-access-email", rateLimitMiddleware.Add(middleware.RateLimitOneTimeAccessEmail), uc.RequestOneTimeAccessEmailAsUnauthenticatedUserHandler) group.DELETE("/users/:id/profile-picture", authMiddleware.Add(), uc.resetUserProfilePictureHandler) group.DELETE("/users/me/profile-picture", authMiddleware.WithAdminNotRequired().Add(), uc.resetCurrentUserProfilePictureHandler) - group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(rate.Every(10*time.Minute), 3), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler) - group.POST("/users/me/verify-email", rateLimitMiddleware.Add(rate.Every(10*time.Second), 5), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler) + group.POST("/users/me/send-email-verification", rateLimitMiddleware.Add(middleware.RateLimitSendEmailVerification), authMiddleware.WithAdminNotRequired().Add(), uc.sendEmailVerificationHandler) + group.POST("/users/me/verify-email", rateLimitMiddleware.Add(middleware.RateLimitVerifyEmail), authMiddleware.WithAdminNotRequired().Add(), uc.verifyEmailHandler) } type UserController struct { diff --git a/backend/internal/job/analytics_job.go b/backend/internal/job/analytics_job.go index c2e658df..6a9adfb3 100644 --- a/backend/internal/job/analytics_job.go +++ b/backend/internal/job/analytics_job.go @@ -5,11 +5,12 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "net/http" "time" backoff "github.com/cenkalti/backoff/v5" - "github.com/go-co-op/gocron/v2" + "github.com/italypaleale/francis/builtin/cronjob" "github.com/pocket-id/pocket-id/backend/internal/common" "github.com/pocket-id/pocket-id/backend/internal/service" @@ -17,23 +18,60 @@ import ( const heartbeatUrl = "https://analytics.pocket-id.org/heartbeat" -func (s *Scheduler) RegisterAnalyticsJob(ctx context.Context, appConfig *service.AppConfigService, httpClient *http.Client) error { +// GetAnalyticsJob returns the CronJob actor +func GetAnalyticsJob(appConfig *service.AppConfigService, httpClient *http.Client) (*cronjob.CronJob, error) { // Skip if analytics are disabled or not in production environment if common.EnvConfig.AnalyticsDisabled || !common.EnvConfig.AppEnv.IsProduction() { - return nil + return nil, nil } - // Send every 24 hours - jobs := &AnalyticsJob{ - appConfig: appConfig, + job := &AnalyticsJob{ httpClient: httpClient, } - return s.RegisterJob(ctx, "SendHeartbeat", gocron.DurationJob(24*time.Hour), jobs.sendHeartbeat, service.RegisterJobOpts{RunImmediately: true}) + err := job.createBody(appConfig) + if err != nil { + return nil, fmt.Errorf("error pre-computing request body: %w", err) + } + + // Create the built-in actor + cj, err := cronjob.New( + "Analytics", + cronjob.WithJob(job.sendHeartbeat), + // Run every 24 hours + cronjob.WithInterval(24*time.Hour), + // Run immediately upon registration too + cronjob.WithImmediate(), + cronjob.WithLogger(slog.Default()), + ) + if err != nil { + return nil, fmt.Errorf("error creating Analytics job: %w", err) + } + + return cj, nil } type AnalyticsJob struct { - appConfig *service.AppConfigService httpClient *http.Client + body []byte +} + +// createBody pre-computes the body for all requests +func (j *AnalyticsJob) createBody(appConfig *service.AppConfigService) error { + body, err := json.Marshal(struct { + Version string `json:"version"` + InstanceID string `json:"instance_id"` + }{ + Version: common.Version, + InstanceID: appConfig.GetDbConfig().InstanceID.Value, + }) + if err != nil { + return fmt.Errorf("failed to marshal heartbeat body: %w", err) + } + + // Set the body in the object + j.body = body + + return nil } // sendHeartbeat sends a heartbeat to the analytics service @@ -43,23 +81,13 @@ func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error { return nil } - body, err := json.Marshal(struct { - Version string `json:"version"` - InstanceID string `json:"instance_id"` - }{ - Version: common.Version, - InstanceID: j.appConfig.GetDbConfig().InstanceID.Value, - }) - if err != nil { - return fmt.Errorf("failed to marshal heartbeat body: %w", err) - } - - _, err = backoff.Retry( + // Use a backoff to retry + _, err := backoff.Retry( parentCtx, func() (struct{}, error) { ctx, cancel := context.WithTimeout(parentCtx, 20*time.Second) defer cancel() - req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, heartbeatUrl, bytes.NewReader(j.body)) if err != nil { return struct{}{}, fmt.Errorf("failed to create request: %w", err) } @@ -77,7 +105,6 @@ func (j *AnalyticsJob) sendHeartbeat(parentCtx context.Context) error { backoff.WithBackOff(backoff.NewExponentialBackOff()), backoff.WithMaxTries(3), ) - if err != nil { return fmt.Errorf("heartbeat request failed: %w", err) } diff --git a/backend/internal/job/db_cleanup_job.go b/backend/internal/job/db_cleanup_job.go index cc092d92..24bbc4d3 100644 --- a/backend/internal/job/db_cleanup_job.go +++ b/backend/internal/job/db_cleanup_job.go @@ -123,8 +123,8 @@ func (j *DbCleanupJobs) clearInteractionSessions(ctx context.Context) error { return nil } -// clearReauthenticationTokens deletes expired reauthentication tokens. What counts as -// expired is owned by the webauthn module. +// clearReauthenticationTokens deletes expired reauthentication tokens. +// What counts as expired is owned by the webauthn module. func (j *DbCleanupJobs) clearReauthenticationTokens(ctx context.Context) error { count, err := webauthn.CleanupExpiredReauthenticationTokens(ctx, j.db) if err != nil { diff --git a/backend/internal/job/file_cleanup_job.go b/backend/internal/job/file_cleanup_job.go index 71f0dd34..8e5ef6eb 100644 --- a/backend/internal/job/file_cleanup_job.go +++ b/backend/internal/job/file_cleanup_job.go @@ -2,37 +2,56 @@ package job import ( "context" - "errors" "fmt" "log/slog" "path" "strings" "time" - "github.com/go-co-op/gocron/v2" + "github.com/italypaleale/francis/builtin/cronjob" "gorm.io/gorm" "github.com/pocket-id/pocket-id/backend/internal/model" - "github.com/pocket-id/pocket-id/backend/internal/service" "github.com/pocket-id/pocket-id/backend/internal/storage" ) -func (s *Scheduler) RegisterFileCleanupJobs(ctx context.Context, db *gorm.DB, fileStorage storage.FileStorage) error { - jobs := &FileCleanupJobs{db: db, fileStorage: fileStorage} - - var errs []error - errs = append(errs, - s.RegisterJob(ctx, "ClearUnusedDefaultProfilePictures", gocron.DurationJob(24*time.Hour), jobs.clearUnusedDefaultProfilePictures, service.RegisterJobOpts{}), - ) - - // Only necessary for file system storage - if fileStorage.Type() == storage.TypeFileSystem { - errs = append(errs, - s.RegisterJob(ctx, "ClearOrphanedTempFiles", gocron.DurationJob(12*time.Hour), jobs.clearOrphanedTempFiles, service.RegisterJobOpts{RunImmediately: true}), - ) +// GetFileCleanupJobs returns the CronJob actors +func GetFileCleanupJobs(db *gorm.DB, fileStorage storage.FileStorage) (cjs []*cronjob.CronJob, err error) { + job := &FileCleanupJobs{ + db: db, + fileStorage: fileStorage, } - return errors.Join(errs...) + // Create the built-in actor for the ClearUnusedDefaultProfilePictures job + cj, err := cronjob.New( + "ClearUnusedDefaultProfilePictures", + cronjob.WithJob(job.clearUnusedDefaultProfilePictures), + // Run every 24 hours + cronjob.WithInterval(24*time.Hour), + cronjob.WithLogger(slog.Default()), + ) + if err != nil { + return nil, fmt.Errorf("error creating ClearUnusedDefaultProfilePictures job: %w", err) + } + cjs = append(cjs, cj) + + // Create the built-in actor for the ClearOrphanedTempFiles job + // Only necessary for file system storage + if fileStorage.Type() == storage.TypeFileSystem { + cj, err := cronjob.New( + "ClearOrphanedTempFiles", + cronjob.WithJob(job.clearOrphanedTempFiles), + // Run every 12 hours + cronjob.WithInterval(12*time.Hour), + cronjob.WithLogger(slog.Default()), + ) + if err != nil { + return nil, fmt.Errorf("error creating ClearOrphanedTempFiles job: %w", err) + } + cjs = append(cjs, cj) + } + + return cjs, nil } type FileCleanupJobs struct { @@ -91,7 +110,7 @@ func (j *FileCleanupJobs) clearUnusedDefaultProfilePictures(ctx context.Context) func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error { const minAge = 10 * time.Minute - var deleted int + deleted := 0 err := j.fileStorage.Walk(ctx, "/", func(p storage.ObjectInfo) error { // Only temp files if !strings.HasSuffix(p.Path, "-tmp") { @@ -110,7 +129,6 @@ func (j *FileCleanupJobs) clearOrphanedTempFiles(ctx context.Context) error { deleted++ return nil }) - if err != nil { return fmt.Errorf("failed to scan storage: %w", err) } diff --git a/backend/internal/middleware/rate_limit.go b/backend/internal/middleware/rate_limit.go index 11da4a09..2a9ec391 100644 --- a/backend/internal/middleware/rate_limit.go +++ b/backend/internal/middleware/rate_limit.go @@ -1,34 +1,85 @@ package middleware import ( - "sync" + "context" + "errors" + "log/slog" + "math" + "net/http" + "strconv" "time" - "github.com/pocket-id/pocket-id/backend/internal/common" - "github.com/gin-gonic/gin" - "golang.org/x/time/rate" + "github.com/italypaleale/francis/builtin/ratelimit" + + "github.com/pocket-id/pocket-id/backend/internal/common" ) -type RateLimitMiddleware struct{} +// Rate-limit policy names +// Each constant names a limiter registered on the actor host and is the value passed to Add to select that limiter +const ( + RateLimitAPI = "api" + RateLimitSignup = "signup" + RateLimitWebauthnLogin = "webauthn-login" + RateLimitWebauthnReauthenticate = "webauthn-reauthenticate" + RateLimitOneTimeAccessToken = "one-time-access-token" + RateLimitOneTimeAccessEmail = "one-time-access-email" + RateLimitSendEmailVerification = "send-email-verification" + RateLimitVerifyEmail = "verify-email" +) -func NewRateLimitMiddleware() *RateLimitMiddleware { - return &RateLimitMiddleware{} +// RateLimitPolicy is the configuration for a single rate-limit actor +// Each policy maps to one Francis rate-limit actor type and requests are keyed by client IP, so every IP is limited independently and per-route limits stay isolated from each other +type RateLimitPolicy struct { + // Name must be unique across policies and must not contain '/' + Name string + // Rate is the number of calls admitted per Per window + Rate int + // Per is the window the rate applies over + Per time.Duration + // Burst is the token bucket's capacity, i.e. how many calls may be admitted instantly before throttling kicks in + Burst int } -func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { +// RateLimitPolicies returns the configuration for every rate-limit policy +// The slice is built on each call so the policies are not retained at the package level, and the actor host registers one limiter per entry +func RateLimitPolicies() []RateLimitPolicy { + return []RateLimitPolicy{ + {Name: RateLimitAPI, Rate: 1, Per: time.Second, Burst: 100}, + {Name: RateLimitSignup, Rate: 1, Per: time.Minute, Burst: 10}, + {Name: RateLimitWebauthnLogin, Rate: 1, Per: 10 * time.Second, Burst: 5}, + {Name: RateLimitWebauthnReauthenticate, Rate: 1, Per: 10 * time.Second, Burst: 5}, + {Name: RateLimitOneTimeAccessToken, Rate: 1, Per: 10 * time.Second, Burst: 5}, + {Name: RateLimitOneTimeAccessEmail, Rate: 1, Per: 10 * time.Minute, Burst: 3}, + {Name: RateLimitSendEmailVerification, Rate: 1, Per: 10 * time.Minute, Burst: 3}, + {Name: RateLimitVerifyEmail, Rate: 1, Per: 10 * time.Second, Burst: 5}, + } +} + +type RateLimitMiddleware struct { + services map[string]*ratelimit.RateLimitService +} + +func NewRateLimitMiddleware(services map[string]*ratelimit.RateLimitService) *RateLimitMiddleware { + return &RateLimitMiddleware{ + services: services, + } +} + +func (m *RateLimitMiddleware) Add(policy string) gin.HandlerFunc { if common.EnvConfig.DisableRateLimiting { return func(c *gin.Context) { c.Next() } } - // Map to store the rate limiters per IP - var clients = make(map[string]*client) - var mu sync.Mutex - - // Start the cleanup routine - go cleanupClients(&mu, clients) + // A missing service means the policy was never registered on the actor host, which is a development-time errror + svc := m.services[policy] + if svc == nil { + return func(c *gin.Context) { + c.AbortWithStatus(http.StatusInternalServerError) + } + } return func(c *gin.Context) { ip := c.ClientIP() @@ -40,8 +91,23 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { return } - limiter := getLimiter(ip, limit, burst, &mu, clients) - if !limiter.Allow() { + // Allow is a non-blocking token-bucket check keyed by client IP: it consumes a slot and reports whether the call is admitted right now + allowed, retryAfter, err := svc.Allow(c.Request.Context(), ip) + if err != nil { + // Fail open so a limiter error does not turn away otherwise-valid traffic + if !errors.Is(err, context.Canceled) { + // A cancelled context just means the client went away, so it is not worth logging + slog.WarnContext(c.Request.Context(), "Rate limiter unavailable, allowing request", slog.String("policy", policy), slog.Any("error", err)) + } + c.Next() + return + } + + if !allowed { + // Advertise when the caller may retry, mapping the limiter's delay onto a Retry-After header + if retryAfter > 0 { + c.Header("Retry-After", strconv.Itoa(int(math.Ceil(retryAfter.Seconds())))) + } _ = c.Error(&common.TooManyRequestsError{}) c.Abort() return @@ -50,37 +116,3 @@ func (m *RateLimitMiddleware) Add(limit rate.Limit, burst int) gin.HandlerFunc { c.Next() } } - -type client struct { - limiter *rate.Limiter - lastSeen time.Time -} - -// Cleanup routine to remove stale clients that haven't been seen for a while -func cleanupClients(mu *sync.Mutex, clients map[string]*client) { - for { - time.Sleep(time.Minute) - mu.Lock() - for ip, client := range clients { - if time.Since(client.lastSeen) > 3*time.Minute { - delete(clients, ip) - } - } - mu.Unlock() - } -} - -// getLimiter retrieves the rate limiter for a given IP address, creating one if it doesn't exist -func getLimiter(ip string, limit rate.Limit, burst int, mu *sync.Mutex, clients map[string]*client) *rate.Limiter { - mu.Lock() - defer mu.Unlock() - - if client, exists := clients[ip]; exists { - client.lastSeen = time.Now() - return client.limiter - } - - limiter := rate.NewLimiter(limit, burst) - clients[ip] = &client{limiter: limiter, lastSeen: time.Now()} - return limiter -} diff --git a/backend/internal/middleware/rate_limit_test.go b/backend/internal/middleware/rate_limit_test.go new file mode 100644 index 00000000..eba3885e --- /dev/null +++ b/backend/internal/middleware/rate_limit_test.go @@ -0,0 +1,147 @@ +package middleware + +import ( + "context" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + "github.com/italypaleale/francis/builtin/ratelimit" + "github.com/italypaleale/francis/host/local" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" + testutils "github.com/pocket-id/pocket-id/backend/internal/utils/testing" +) + +// startRateLimitServices registers one rate-limit actor per policy on an in-memory test actor host and returns the bound services keyed by policy name +func startRateLimitServices(t *testing.T, policies ...RateLimitPolicy) map[string]*ratelimit.RateLimitService { + t.Helper() + + limiters := make(map[string]*ratelimit.RateLimit, len(policies)) + opts := make([]local.HostOption, 0, len(policies)) + for _, p := range policies { + rl, err := ratelimit.New(p.Name, ratelimit.WithRate(p.Rate), ratelimit.WithPer(p.Per), ratelimit.WithBurst(p.Burst)) + require.NoError(t, err) + limiters[p.Name] = rl + opts = append(opts, local.WithBuiltInActor(rl)) + } + + h := testutils.NewActorHostForTest(t, opts...) + + services := make(map[string]*ratelimit.RateLimitService, len(limiters)) + svc := h.Service() + for name, rl := range limiters { + services[name] = rl.Service(svc) + } + return services +} + +// newRateLimitRouter builds a gin engine that runs the rate-limit middleware for the given policy on GET /test +// Trusted proxies are disabled so ClientIP resolves to the request's RemoteAddr, and the error handler turns the middleware's error into a 429 response +func newRateLimitRouter(t *testing.T, services map[string]*ratelimit.RateLimitService, policy string) *gin.Engine { + t.Helper() + + r := gin.New() + + err := r.SetTrustedProxies(nil) + require.NoError(t, err) + + r.Use(NewErrorHandlerMiddleware().Add()) + + mw := NewRateLimitMiddleware(services) + r.GET("/test", mw.Add(policy), func(c *gin.Context) { + c.String(http.StatusOK, "ok") + }) + + return r +} + +// doRateLimitRequest sends a GET /test request as the given client IP and returns the response recorder +func doRateLimitRequest(ctx context.Context, r *gin.Engine, ip string) *httptest.ResponseRecorder { + req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/test", nil) + req.RemoteAddr = net.JoinHostPort(ip, "12345") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + return w +} + +func TestRateLimitMiddleware(t *testing.T) { + gin.SetMode(gin.TestMode) + + // The middleware reads the global config, so restore it after the test + originalEnvConfig := common.EnvConfig + t.Cleanup(func() { + common.EnvConfig = originalEnvConfig + }) + + common.EnvConfig.AppEnv = common.AppEnvProduction + common.EnvConfig.DisableRateLimiting = false + + // A slow refill (one token per hour) with a burst of 2 means the first two calls for a key are admitted and the third is rejected, with no refill during the test + const policy = "test-limit" + services := startRateLimitServices(t, RateLimitPolicy{Name: policy, Rate: 1, Per: time.Hour, Burst: 2}) + + t.Run("rejects requests over the limit and sets Retry-After", func(t *testing.T) { + r := newRateLimitRouter(t, services, policy) + const ip = "203.0.113.1" + + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, ip).Code) + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, ip).Code) + + w := doRateLimitRequest(t.Context(), r, ip) + require.Equal(t, http.StatusTooManyRequests, w.Code) + assert.NotEmpty(t, w.Header().Get("Retry-After"), "a throttled response should advertise Retry-After") + }) + + t.Run("limits each client IP independently", func(t *testing.T) { + r := newRateLimitRouter(t, services, policy) + + // Exhaust the budget for the first IP + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.2").Code) + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.2").Code) + require.Equal(t, http.StatusTooManyRequests, doRateLimitRequest(t.Context(), r, "203.0.113.2").Code) + + // A different IP still has its full budget + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.3").Code) + }) + + t.Run("allows all requests when rate limiting is disabled", func(t *testing.T) { + common.EnvConfig.DisableRateLimiting = true + t.Cleanup(func() { common.EnvConfig.DisableRateLimiting = false }) + + r := newRateLimitRouter(t, services, policy) + for range 5 { + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.4").Code) + } + }) + + t.Run("skips rate limiting for loopback addresses", func(t *testing.T) { + r := newRateLimitRouter(t, services, policy) + for _, ip := range []string{"127.0.0.1", "::1"} { + for range 5 { + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, ip).Code) + } + } + }) + + t.Run("skips rate limiting in the test environment", func(t *testing.T) { + common.EnvConfig.AppEnv = common.AppEnvTest + t.Cleanup(func() { common.EnvConfig.AppEnv = common.AppEnvProduction }) + + r := newRateLimitRouter(t, services, policy) + for range 5 { + require.Equal(t, http.StatusOK, doRateLimitRequest(t.Context(), r, "203.0.113.5").Code) + } + }) + + t.Run("fails with 500 when the policy is not registered", func(t *testing.T) { + // An unknown policy has no bound service, which is a configuration error surfaced as a 500 + r := newRateLimitRouter(t, services, "does-not-exist") + require.Equal(t, http.StatusInternalServerError, doRateLimitRequest(t.Context(), r, "203.0.113.6").Code) + }) +} diff --git a/backend/internal/model/types/encrypted_string.go b/backend/internal/model/types/encrypted_string.go index 846274b3..2bd38aae 100644 --- a/backend/internal/model/types/encrypted_string.go +++ b/backend/internal/model/types/encrypted_string.go @@ -67,14 +67,17 @@ func (e EncryptedString) String() string { } // DeriveEncryptedStringKey derives a key for encrypting EncryptedString values from the master key. +// Note: changing this function in any is considered a breaking change that will make all encrypted stored data irrecoverable. func DeriveEncryptedStringKey(master []byte) ([]byte, error) { const info = "pocketid/encrypted_string" r := hkdf.New(sha256.New, master, nil, []byte(info)) key := make([]byte, 32) - if _, err := io.ReadFull(r, key); err != nil { + _, err := io.ReadFull(r, key) + if err != nil { return nil, err } + return key, nil } diff --git a/backend/internal/model/types/encrypted_string_test.go b/backend/internal/model/types/encrypted_string_test.go new file mode 100644 index 00000000..1f9a0ed3 --- /dev/null +++ b/backend/internal/model/types/encrypted_string_test.go @@ -0,0 +1,20 @@ +package datatype + +import ( + "encoding/hex" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestDeriveEncryptedStringKeyUsesStableValue(t *testing.T) { + masterKey := []byte("test-encryption-key") + + expectedHex := "8a08281e815dac248bd216b8ceff063c390f3515475909bfd4380ed640bd854e" + expected, err := hex.DecodeString(expectedHex) + require.NoError(t, err) + + actual, err := DeriveEncryptedStringKey(masterKey) + require.NoError(t, err) + require.Equal(t, expected, actual) +} diff --git a/backend/internal/oidc/device_service_test.go b/backend/internal/oidc/device_service_test.go index 989301c3..8a451bab 100644 --- a/backend/internal/oidc/device_service_test.go +++ b/backend/internal/oidc/device_service_test.go @@ -101,7 +101,7 @@ func newTestDeviceServiceWithCode(t *testing.T, clientID, userID string, require provider, err := newProvider(store, nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) diff --git a/backend/internal/oidc/introspection_handler_test.go b/backend/internal/oidc/introspection_handler_test.go index c12c70f9..53d5365c 100644 --- a/backend/internal/oidc/introspection_handler_test.go +++ b/backend/internal/oidc/introspection_handler_test.go @@ -40,7 +40,7 @@ func TestIntrospectionHandlerBindsTokenToCallerClient(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) @@ -144,7 +144,7 @@ func TestIntrospectionHandlerAllowsReusedFederatedClientAssertion(t *testing.T) provider, err := newProvider(store, authenticator, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: baseURL, TokenBaseURL: baseURL, - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) diff --git a/backend/internal/oidc/module.go b/backend/internal/oidc/module.go index 00b21b8d..b0952ab6 100644 --- a/backend/internal/oidc/module.go +++ b/backend/internal/oidc/module.go @@ -15,7 +15,7 @@ import ( type Config struct { BaseURL string TokenBaseURL string - Secret string + Secret []byte } type TokenSigner interface { diff --git a/backend/internal/oidc/preview_test.go b/backend/internal/oidc/preview_test.go index 4ba9737e..87787273 100644 --- a/backend/internal/oidc/preview_test.go +++ b/backend/internal/oidc/preview_test.go @@ -19,7 +19,7 @@ func TestClientPreviewBuilderUsesFositeTokenStrategies(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) @@ -66,7 +66,7 @@ func TestClientPreviewBuilderRejectsInvalidScope(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) diff --git a/backend/internal/oidc/provider.go b/backend/internal/oidc/provider.go index 1b7a0cba..5dff50b6 100644 --- a/backend/internal/oidc/provider.go +++ b/backend/internal/oidc/provider.go @@ -126,13 +126,16 @@ func matchRedirectURI(rawurl string, client fosite.Client) (*url.URL, error) { } // DeriveGlobalSecret derives a 32-byte secret from the provided secret. -func DeriveGlobalSecret(secret string) ([]byte, error) { +// Note: changing this function in any is considered a breaking change. +func DeriveGlobalSecret(secret []byte) ([]byte, error) { const info = "pocketid/fosite_global_secret" - r := hkdf.New(sha256.New, []byte(secret), nil, []byte(info)) + r := hkdf.New(sha256.New, secret, nil, []byte(info)) key := make([]byte, 32) - if _, err := io.ReadFull(r, key); err != nil { + _, err := io.ReadFull(r, key) + if err != nil { return nil, err } + return key, nil } diff --git a/backend/internal/oidc/provider_test.go b/backend/internal/oidc/provider_test.go index 8d9b5a03..16e92d8c 100644 --- a/backend/internal/oidc/provider_test.go +++ b/backend/internal/oidc/provider_test.go @@ -7,6 +7,7 @@ import ( "crypto/rand" "crypto/rsa" "encoding/base64" + "encoding/hex" "encoding/json" "net/http" "net/http/httptest" @@ -37,6 +38,18 @@ func (s testTokenSigner) GetKeyID() (string, bool) { return "test-key-id", true } +func TestDeriveGlobalSecretUsesStableValue(t *testing.T) { + masterSecret := []byte("test-secret") + + expectedHex := "82de1690a30923a038d722a72e9599087484732bf9c1e8af5fc620f8fa87c08b" + expected, err := hex.DecodeString(expectedHex) + require.NoError(t, err) + + actual, err := DeriveGlobalSecret(masterSecret) + require.NoError(t, err) + require.Equal(t, expected, actual) +} + func TestProviderIssuesJWTAccessTokens(t *testing.T) { db := testutils.NewDatabaseForTest(t) signerKey, err := rsa.GenerateKey(rand.Reader, 2048) @@ -45,7 +58,7 @@ func TestProviderIssuesJWTAccessTokens(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) @@ -85,7 +98,7 @@ func TestProviderAcceptsWildcardRedirectURI(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) @@ -118,7 +131,7 @@ func TestProviderAcceptsPushedAuthorizationWildcardRedirectURI(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) @@ -150,7 +163,7 @@ func TestProviderRejectsUnmatchedWildcardRedirectURI(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: signerKey}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) @@ -217,7 +230,7 @@ func TestProviderIssuesAndValidatesTokensForSupportedAlgorithms(t *testing.T) { provider, err := newProvider(NewStore(db), nil, algTestSigner{key: tc.gen(t), alg: tc.alg}, Config{ //nolint:gosec // static test-only provider secret BaseURL: "https://issuer.example.com", TokenBaseURL: "https://issuer.example.com", - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) diff --git a/backend/internal/oidc/token_handler_test.go b/backend/internal/oidc/token_handler_test.go index 9355e0b1..0adee2fa 100644 --- a/backend/internal/oidc/token_handler_test.go +++ b/backend/internal/oidc/token_handler_test.go @@ -57,7 +57,7 @@ func TestTokenHandlerClientCredentialsGrant(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{ BaseURL: baseURL, TokenBaseURL: baseURL, - Secret: secret, + Secret: []byte(secret), }) require.NoError(t, err) handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil)) @@ -124,7 +124,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) { // real refresh grant resolves it. mintRefreshToken := func(t *testing.T, db *gorm.DB, clientID, userID string) string { t.Helper() - globalSecret, err := DeriveGlobalSecret(secret) + globalSecret, err := DeriveGlobalSecret([]byte(secret)) require.NoError(t, err) strategy := compose.NewOAuth2HMACStrategy(&fosite.Config{ GlobalSecret: globalSecret, @@ -164,7 +164,7 @@ func TestTokenHandlerRefreshGrantRevalidatesUser(t *testing.T) { provider, err := newProvider(NewStore(db), nil, signer, Config{ BaseURL: baseURL, TokenBaseURL: baseURL, - Secret: secret, + Secret: []byte(secret), }) require.NoError(t, err) handler := newTokenHandler(provider, newClaimsService(db, nil, baseURL, nil)) diff --git a/backend/internal/oidc/userinfo_handler_test.go b/backend/internal/oidc/userinfo_handler_test.go index 5335773f..a72db276 100644 --- a/backend/internal/oidc/userinfo_handler_test.go +++ b/backend/internal/oidc/userinfo_handler_test.go @@ -49,7 +49,7 @@ func TestUserInfoHandler(t *testing.T) { provider, err := newProvider(NewStore(db), nil, testTokenSigner{key: key}, Config{ BaseURL: baseURL, TokenBaseURL: baseURL, - Secret: "test-secret", + Secret: []byte("test-secret"), }) require.NoError(t, err) diff --git a/backend/internal/service/e2etest_service.go b/backend/internal/service/e2etest_service.go index ed3f2e75..0a824c88 100644 --- a/backend/internal/service/e2etest_service.go +++ b/backend/internal/service/e2etest_service.go @@ -485,23 +485,37 @@ func (s *TestService) SeedDatabase(baseURL string) error { } func (s *TestService) ResetDatabase() error { - err := s.db.Transaction(func(tx *gorm.DB) error { + return s.db.Transaction(func(tx *gorm.DB) (err error) { var tables []string + // The "francis_" tables belong to the actor host and must be preserved: wiping them out from under the running host breaks it switch common.EnvConfig.DbProvider { case common.DbProviderSqlite: // Query to get all tables for SQLite - if err := tx.Raw("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name != 'schema_migrations';").Scan(&tables).Error; err != nil { - return err + err = tx. + Raw(`SELECT name + FROM sqlite_master + WHERE type='table' + AND name NOT LIKE 'sqlite_%' + AND name NOT LIKE 'francis_%' + AND name != 'schema_migrations'`). + Scan(&tables). + Error + if err != nil { + return fmt.Errorf("error loading table list: %w", err) } case common.DbProviderPostgres: // Query to get all tables for PostgreSQL - if err := tx.Raw(` - SELECT tablename - FROM pg_tables - WHERE schemaname = 'public' AND tablename != 'schema_migrations'; - `).Scan(&tables).Error; err != nil { - return err + err = tx. + Raw(`SELECT tablename + FROM pg_tables + WHERE schemaname = 'public' + AND tablename NOT LIKE 'francis_%' + AND tablename != 'schema_migrations'`). + Scan(&tables). + Error + if err != nil { + return fmt.Errorf("error loading table list: %w", err) } default: return fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) @@ -509,15 +523,14 @@ func (s *TestService) ResetDatabase() error { // Delete all rows from all tables for _, table := range tables { - if err := tx.Exec(fmt.Sprintf("DELETE FROM %s;", table)).Error; err != nil { - return err + err = tx.Exec("DELETE FROM " + table).Error + if err != nil { + return fmt.Errorf("error deleting from table '%s': %w", table, err) } } return nil }) - - return err } func (s *TestService) ResetApplicationImages(ctx context.Context) error { @@ -634,7 +647,7 @@ func (s *TestService) SetLdapTestConfig(ctx context.Context) error { } func (s *TestService) SignRefreshToken(ctx context.Context, userID, clientID, fixtureRefreshToken string) (string, error) { - globalSecret, err := oidc.DeriveGlobalSecret(string(common.EnvConfig.EncryptionKey)) + globalSecret, err := oidc.DeriveGlobalSecret(common.EnvConfig.EncryptionKey) if err != nil { return "", err } @@ -702,7 +715,7 @@ func seededRefreshTokenFixture(userID string, clientID string, fixtureRefreshTok } func (s *TestService) SignAccessToken(ctx context.Context, userID, clientID string, expired bool) (string, error) { - globalSecret, err := oidc.DeriveGlobalSecret(string(common.EnvConfig.EncryptionKey)) + globalSecret, err := oidc.DeriveGlobalSecret(common.EnvConfig.EncryptionKey) if err != nil { return "", err } diff --git a/backend/internal/service/export_service.go b/backend/internal/service/export_service.go index 1ce5b259..616017eb 100644 --- a/backend/internal/service/export_service.go +++ b/backend/internal/service/export_service.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "path/filepath" + "strings" "gorm.io/gorm" @@ -60,7 +61,8 @@ func (s *ExportService) extractDatabase() (DatabaseExport, error) { } for table := range schema { - if table == "storage" || table == "schema_migrations" { + // Skip internal tables and the actor host's own "francis_" tables + if table == "storage" || table == "schema_migrations" || strings.HasPrefix(table, "francis_") { continue } err = s.dumpTable(table, schema[table], &out) @@ -74,7 +76,8 @@ func (s *ExportService) extractDatabase() (DatabaseExport, error) { func (s *ExportService) schemaVersion() (uint, error) { var version uint - if err := s.db.Raw("SELECT version FROM schema_migrations").Row().Scan(&version); err != nil { + err := s.db.Raw("SELECT version FROM schema_migrations").Row().Scan(&version) + if err != nil { return 0, fmt.Errorf("failed to query schema version: %w", err) } return version, nil @@ -108,7 +111,8 @@ func (s *ExportService) dumpTable(table string, types utils.DBSchemaTableTypes, // Skip the app lock row in the kv table if table == "kv" { - if keyPtr, ok := rowMap["key"].(*string); ok && keyPtr != nil && *keyPtr == lockKey { + keyPtr, ok := rowMap["key"].(*string) + if ok && keyPtr != nil && *keyPtr == lockKey { continue } } @@ -181,16 +185,23 @@ func (s *ExportService) writeExportZipStream(ctx context.Context, w io.Writer, d jsonEncoder := json.NewEncoder(jsonWriter) jsonEncoder.SetEscapeHTML(false) - if err := jsonEncoder.Encode(dbData); err != nil { + err = jsonEncoder.Encode(dbData) + if err != nil { return fmt.Errorf("failed to encode database.json: %w", err) } // Add uploaded files - if err := s.addUploadsToZip(ctx, zipWriter); err != nil { - return err + err = s.addUploadsToZip(ctx, zipWriter) + if err != nil { + return fmt.Errorf("error adding uploads to the export zip: %w", err) } - return zipWriter.Close() + err = zipWriter.Close() + if err != nil { + return fmt.Errorf("error closing the zip writer: %w", err) + } + + return nil } // addUploadsToZip adds all files from the storage to the ZIP archive under the "uploads/" directory @@ -200,17 +211,18 @@ func (s *ExportService) addUploadsToZip(ctx context.Context, zipWriter *zip.Writ w, err := zipWriter.Create(zipPath) if err != nil { - return fmt.Errorf("failed to create zip entry for %s: %w", zipPath, err) + return fmt.Errorf("failed to create zip entry for '%s': %w", zipPath, err) } f, _, err := s.storage.Open(ctx, p.Path) if err != nil { - return fmt.Errorf("failed to open file %s: %w", zipPath, err) + return fmt.Errorf("failed to open file '%s': %w", zipPath, err) } defer f.Close() - if _, err := io.Copy(w, f); err != nil { - return fmt.Errorf("failed to copy file %s into zip: %w", zipPath, err) + _, err = io.Copy(w, f) + if err != nil { + return fmt.Errorf("failed to copy file '%s' into zip: %w", zipPath, err) } return nil }) diff --git a/backend/internal/service/export_service_test.go b/backend/internal/service/export_service_test.go new file mode 100644 index 00000000..5ef2f0af --- /dev/null +++ b/backend/internal/service/export_service_test.go @@ -0,0 +1,30 @@ +package service + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +// TestExportExcludesActorHostTables verifies that an export does not dump the actor host's own "francis_" tables +// They hold volatile runtime state (host registrations, alarms, …), are not part of a Pocket ID export, and including them made the CLI export/import comparison tests fail +func TestExportExcludesActorHostTables(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "pocket-id.db") + db := openImportTestDB(t, dbPath, nil) + defer closeImportTestDB(db) + + sqlDB, _ := db.DB() + require.NoError(t, utils.MigrateDatabase(t.Context(), sqlDB)) + seedActorHostSchema(t, db) // creates francis_active_actors (with a row) and a view over it + + export, err := NewExportService(db, nil).extractDatabase() + require.NoError(t, err) + + for table := range export.Tables { + require.Falsef(t, strings.HasPrefix(table, "francis_"), "export must not include actor host table %q", table) + } +} diff --git a/backend/internal/service/import_service.go b/backend/internal/service/import_service.go index 278c0b15..857c09cb 100644 --- a/backend/internal/service/import_service.go +++ b/backend/internal/service/import_service.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + "database/sql" "encoding/base64" "encoding/json" "errors" @@ -46,7 +47,7 @@ func (s *ImportService) ImportFromZip(ctx context.Context, r *zip.Reader) error return err } - err = s.ImportDatabase(dbData) + err = s.ImportDatabase(ctx, dbData) if err != nil { return err } @@ -60,8 +61,8 @@ func (s *ImportService) ImportFromZip(ctx context.Context, r *zip.Reader) error } // ImportDatabase only imports the database data from the given DatabaseExport struct. -func (s *ImportService) ImportDatabase(dbData DatabaseExport) error { - err := s.resetSchema(dbData.Version) +func (s *ImportService) ImportDatabase(ctx context.Context, dbData DatabaseExport) error { + err := s.resetSchema(ctx, dbData.Version) if err != nil { return err } @@ -143,36 +144,35 @@ func (s *ImportService) importUploads(ctx context.Context, files []*zip.File) er return nil } -// resetSchema drops the existing schema and migrates to the target version -func (s *ImportService) resetSchema(targetVersion uint) error { +// resetSchema drops the existing Pocket ID schema and migrates it to the target version. +// +// It deliberately preserves the actor host's own tables (those with the "francis_" prefix): the actor host owns and migrates them, they are not part of a Pocket ID export, and dropping them here would break the actor host on the next startup. +func (s *ImportService) resetSchema(ctx context.Context, targetVersion uint) error { sqlDb, err := s.db.DB() if err != nil { return fmt.Errorf("failed to get sql.DB: %w", err) } - m, err := utils.GetEmbeddedMigrateInstance(sqlDb) - if err != nil { - return fmt.Errorf("failed to get migrate instance: %w", err) + // Drop the existing Pocket ID tables + switch s.db.Name() { + case "sqlite": + err = dropPocketIDTablesSQLite(ctx, sqlDb) + case "postgres": + err = dropPocketIDTablesPostgres(ctx, sqlDb) + default: + err = fmt.Errorf("unsupported database dialect: %s", s.db.Name()) } - - if s.db.Name() == "sqlite" { - s.db.Exec("PRAGMA foreign_keys = OFF;") - } - - err = m.Drop() if err != nil { return fmt.Errorf("failed to drop existing schema: %w", err) } - if s.db.Name() == "sqlite" { - defer s.db.Exec("PRAGMA foreign_keys = ON;") - } - - // Needs to be called again to re-create the schema_migrations table - m, err = utils.GetEmbeddedMigrateInstance(sqlDb) + // Re-create the schema by migrating to the target version + // The migration files manage their own foreign-key state where needed + m, cleanup, err := utils.GetEmbeddedMigrateInstance(ctx, sqlDb) if err != nil { return fmt.Errorf("failed to get migrate instance: %w", err) } + defer cleanup() err = m.Migrate(targetVersion) if err != nil { @@ -182,6 +182,93 @@ func (s *ImportService) resetSchema(targetVersion uint) error { return nil } +// dropPocketIDTablesSQLite drops every Pocket ID table (everything except the actor host's "francis_" tables and SQLite's internal tables) on a single dedicated connection with foreign keys disabled. +// foreign_keys is a per-connection pragma, and DROP TABLE with it enabled performs an implicit DELETE that fires foreign-key cascades/triggers and can fail depending on the drop order, so pinning to one connection keeps enforcement off for every drop. +func dropPocketIDTablesSQLite(ctx context.Context, sqlDb *sql.DB) error { + conn, err := sqlDb.Conn(ctx) + if err != nil { + return err + } + defer conn.Close() + + _, err = conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF") + if err != nil { + return fmt.Errorf("failed to disable foreign keys: %w", err) + } + + var tables []string + rows, err := conn.QueryContext(ctx, ` + SELECT name + FROM sqlite_master + WHERE type = 'table' + AND name NOT LIKE 'sqlite_%' + AND name NOT LIKE 'francis_%'`) + if err != nil { + return fmt.Errorf("failed to list tables: %w", err) + } + for rows.Next() { + var name string + err = rows.Scan(&name) + if err != nil { + // We cannot defer as that would block the subsequent DROP queries + //nolint:sqlclosecheck + _ = rows.Close() + return err + } + tables = append(tables, name) + } + err = errors.Join(rows.Err(), rows.Close()) + if err != nil { + return err + } + + for _, t := range tables { + _, err = conn.ExecContext(ctx, `DROP TABLE IF EXISTS "`+t+`"`) + if err != nil { + return fmt.Errorf("failed to drop table %q: %w", t, err) + } + } + + return nil +} + +// dropPocketIDTablesPostgres drops every Pocket ID table (everything except the actor host's "francis_" tables) +// CASCADE removes dependent objects such as foreign keys. +func dropPocketIDTablesPostgres(ctx context.Context, sqlDb *sql.DB) error { + var tables []string + rows, err := sqlDb.QueryContext(ctx, ` + SELECT tablename + FROM pg_tables + WHERE schemaname = current_schema() + AND tablename NOT LIKE 'francis_%'`) + if err != nil { + return fmt.Errorf("failed to list tables: %w", err) + } + for rows.Next() { + var name string + err = rows.Scan(&name) + if err != nil { + //nolint:sqlclosecheck + _ = rows.Close() + return err + } + tables = append(tables, name) + } + err = errors.Join(rows.Err(), rows.Close()) + if err != nil { + return err + } + + for _, t := range tables { + _, err = sqlDb.ExecContext(ctx, `DROP TABLE IF EXISTS "`+t+`" CASCADE`) + if err != nil { + return fmt.Errorf("failed to drop table %q: %w", t, err) + } + } + + return nil +} + // insertData populates the DB with the imported data func (s *ImportService) insertData(dbData DatabaseExport) error { schema, err := utils.LoadDBSchemaTypes(s.db) @@ -196,9 +283,8 @@ func (s *ImportService) insertData(dbData DatabaseExport) error { tables = append(tables, dbData.TableOrder...) for t := range dbData.Tables { - // Skip tables already present where the order matters - // Also skip the schema_migrations table - if slices.Contains(dbData.TableOrder, t) || t == "schema_migrations" { + // Skip tables already present where the order matters, the schema_migrations table, and the actor host's own "francis_" tables in case they were included + if slices.Contains(dbData.TableOrder, t) || t == "schema_migrations" || strings.HasPrefix(t, "francis_") { continue } tables = append(tables, t) diff --git a/backend/internal/service/import_service_test.go b/backend/internal/service/import_service_test.go new file mode 100644 index 00000000..c7619283 --- /dev/null +++ b/backend/internal/service/import_service_test.go @@ -0,0 +1,111 @@ +package service + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/libtnb/sqlite" + "github.com/stretchr/testify/require" + "gorm.io/gorm" + + "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils" +) + +// importResetTargetVersion is a real migration version the import resets the schema to. +// It only has to be at or beyond 20240817191051 (the rename_config_table migration that toggles foreign keys), so the reset exercises a foreign-key-sensitive migration. +const importResetTargetVersion = 20260726153900 + +// openImportTestDB opens a Gorm SQLite pool on a file database using the same pragmas the application configures in production, most importantly foreign_keys(1) on every connection (normalize() is already registered by the service package's test setup) +func openImportTestDB(t *testing.T, dbPath string, cfg func(*sql.DB)) *gorm.DB { + t.Helper() + common.EnvConfig.DbProvider = common.DbProviderSqlite + + dsn := "file:" + dbPath + "?_txlock=immediate&_pragma=busy_timeout(2500)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)" + + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{TranslateError: true}) + require.NoError(t, err) + + if cfg != nil { + sqlDB, _ := db.DB() + cfg(sqlDB) + } + + return db +} + +func closeImportTestDB(db *gorm.DB) { + sqlDB, err := db.DB() + if err == nil { + _ = sqlDB.Close() + } +} + +// seedActorHostSchema simulates the actor host's own tables and views (the "francis_" prefix), including a view over a table, which an import must preserve. +func seedActorHostSchema(t *testing.T, db *gorm.DB) { + t.Helper() + require.NoError(t, db.Exec(`CREATE TABLE francis_active_actors (id TEXT PRIMARY KEY)`).Error) + require.NoError(t, db.Exec(`CREATE VIEW francis_host_active_actor_count AS SELECT count(*) AS n FROM francis_active_actors`).Error) + require.NoError(t, db.Exec(`INSERT INTO francis_active_actors (id) VALUES ('actor-1')`).Error) +} + +// requireActorHostSchemaPreserved asserts the actor host's tables/views and their data survived an import. +func requireActorHostSchemaPreserved(t *testing.T, db *gorm.DB) { + t.Helper() + var tableRows int64 + require.NoError(t, db.Raw(`SELECT count(*) FROM francis_active_actors`).Scan(&tableRows).Error) + require.Equal(t, int64(1), tableRows, "francis_ tables and their rows must be preserved by an import") + + // The view is only valid if its backing table was preserved as well + var viewCount int64 + require.NoError(t, db.Raw(`SELECT n FROM francis_host_active_actor_count`).Scan(&viewCount).Error) + require.Equal(t, int64(1), viewCount, "francis_ views must be preserved by an import") +} + +// importAndRestart mirrors the CLI import flow (tests/specs/cli.spec.ts): a running instance is stopped, `pocket-id import` replaces the schema and data, and the instance is started again. +// Each step uses a fresh pool to emulate the separate processes involved. importCfg configures the pool the import step uses. +func importAndRestart(importCfg func(*sql.DB)) func(t *testing.T) { + return func(t *testing.T) { + dbPath := filepath.Join(t.TempDir(), "pocket-id.db") + + // Initial boot: connect, migrate, and let the actor host create its own schema. + boot := openImportTestDB(t, dbPath, nil) + sqlDB, _ := boot.DB() + err := utils.MigrateDatabase(t.Context(), sqlDB) + require.NoError(t, err) + seedActorHostSchema(t, boot) + closeImportTestDB(boot) + + // Import: reset the schema (drop + migrate) and load data. Empty tables keep the test focused + // on the schema reset. + imp := openImportTestDB(t, dbPath, importCfg) + err = NewImportService(imp, nil).ImportDatabase(t.Context(), DatabaseExport{ + Provider: "sqlite", + Version: importResetTargetVersion, + Tables: map[string][]map[string]any{}, + }) + closeImportTestDB(imp) + require.NoError(t, err) + + // Restart: migrating again must be a clean no-op, not fail on a dirty database, and the actor + // host's own schema must still be intact + restart := openImportTestDB(t, dbPath, nil) + defer closeImportTestDB(restart) + sqlDB, _ = restart.DB() + err = utils.MigrateDatabase(t.Context(), sqlDB) + require.NoError(t, err) + requireActorHostSchemaPreserved(t, restart) + } +} + +func TestImportResetSchema(t *testing.T) { + importAndRestart(nil)(t) +} + +// TestImportResetSchemaFreshConnections guards the schema reset against connection pooling. +// resetSchema must drop tables with foreign keys disabled on the connection that performs the drops. +// With MaxIdleConns(0) every statement runs on a fresh connection (which the DSN opens with foreign_keys(1)); before dropPocketIDTablesSQLite this reproduced the CI failure where DROP TABLE tripped foreign-key cascades/triggers and left the database dirty, breaking the container restart. +func TestImportResetSchemaFreshConnections(t *testing.T) { + importAndRestart(func(db *sql.DB) { db.SetMaxIdleConns(0) })(t) +} diff --git a/backend/internal/service/scim_service.go b/backend/internal/service/scim_service.go index 3d81b894..b4970165 100644 --- a/backend/internal/service/scim_service.go +++ b/backend/internal/service/scim_service.go @@ -319,7 +319,7 @@ func (s *ScimService) syncGroups( // Update or create groups for _, g := range groups { - existing := getResourceByExternalID[dto.ScimGroup](g.ID, remoteGroups) + existing := getResourceByExternalID(g.ID, remoteGroups) action, err := s.syncGroup(ctx, provider, g, existing, userResources) if err != nil { diff --git a/backend/internal/utils/crypto/crypto_test.go b/backend/internal/utils/crypto/crypto_test.go index c9459dec..677420e9 100644 --- a/backend/internal/utils/crypto/crypto_test.go +++ b/backend/internal/utils/crypto/crypto_test.go @@ -2,6 +2,7 @@ package crypto import ( "crypto/rand" + "encoding/hex" "fmt" "testing" @@ -9,6 +10,19 @@ import ( "github.com/stretchr/testify/require" ) +func TestDeriveKeyUsesStableValueForCustomSeed(t *testing.T) { + masterKey := []byte("test-encryption-key") + seed := "custom-seed-for-derive-key-test" + + expectedHex := "4711f51767f370cef92807634ab9b534476e7f91c1c9914fb681e9982badc646" + expected, err := hex.DecodeString(expectedHex) + require.NoError(t, err) + + actual, err := DeriveKey(masterKey, seed) + require.NoError(t, err) + require.Equal(t, expected, actual) +} + func TestEncryptDecrypt(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/utils/crypto/key.go b/backend/internal/utils/crypto/key.go new file mode 100644 index 00000000..2828a10a --- /dev/null +++ b/backend/internal/utils/crypto/key.go @@ -0,0 +1,24 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/sha3" + "errors" + "fmt" + "hash" +) + +// DeriveKey derives a key using HMAC from the configured masterKey (envConfig.EncryptionKey) and a seed +// Note: changing this function is considered a breaking change +func DeriveKey(masterKey []byte, seed string) (key []byte, err error) { + if len(masterKey) == 0 { + return nil, errors.New("encryption key is empty in the configuration") + } + + // We use HMAC with SHA3-256 here to derive a 256-bit key from the one passed as input + h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(masterKey)) + fmt.Fprint(h, seed) + key = h.Sum(nil) + + return key, nil +} diff --git a/backend/internal/utils/db_migration_util.go b/backend/internal/utils/db_migration_util.go index 00b50d43..2ff146b7 100644 --- a/backend/internal/utils/db_migration_util.go +++ b/backend/internal/utils/db_migration_util.go @@ -1,10 +1,13 @@ package utils import ( + "context" "database/sql" "errors" "fmt" "log/slog" + "strconv" + "strings" "github.com/golang-migrate/migrate/v4" "github.com/golang-migrate/migrate/v4/database" @@ -16,11 +19,12 @@ import ( ) // MigrateDatabase applies database migrations using embedded migration files or fetches them from GitHub if a downgrade is detected. -func MigrateDatabase(sqlDb *sql.DB) error { - m, err := GetEmbeddedMigrateInstance(sqlDb) +func MigrateDatabase(ctx context.Context, sqlDb *sql.DB) error { + m, cleanup, err := GetEmbeddedMigrateInstance(ctx, sqlDb) if err != nil { return fmt.Errorf("failed to get migrate instance: %w", err) } + defer cleanup() path := "migrations/" + string(common.EnvConfig.DbProvider) requiredVersion, err := getRequiredMigrationVersion(path) @@ -35,70 +39,92 @@ func MigrateDatabase(sqlDb *sql.DB) error { return fmt.Errorf("database version (%d) is newer than application version (%d), downgrades are not allowed (set ALLOW_DOWNGRADE=true to enable)", currentVersion, requiredVersion) } slog.Info("Fetching migrations from GitHub to handle possible downgrades") - return migrateDatabaseFromGitHub(sqlDb, requiredVersion, currentVersion) + return migrateDatabaseFromGitHub(ctx, sqlDb, requiredVersion, currentVersion) } err = m.Migrate(requiredVersion) - if err != nil { - if errors.Is(err, migrate.ErrNoChange) { - return nil - } - if errors.As(err, &migrate.ErrDirty{}) { - return fmt.Errorf("database migration failed. Please create an issue on GitHub and temporarely downgrade to the previous version: %w", err) - } + switch { + case errors.Is(err, migrate.ErrNoChange): + // All good + return nil + case errors.As(err, &migrate.ErrDirty{}): + return fmt.Errorf("database migration failed. Please create an issue on GitHub and temporarely downgrade to the previous version: %w", err) + case err != nil: return fmt.Errorf("failed to apply embedded migrations: %w", err) } + return nil } // GetEmbeddedMigrateInstance creates a migrate.Migrate instance using embedded migration files. -func GetEmbeddedMigrateInstance(sqlDb *sql.DB) (*migrate.Migrate, error) { +// The returned cleanup function must always be called once the instance is no longer needed: for Postgres it releases the dedicated connection the driver holds (see newMigrationDriver). +func GetEmbeddedMigrateInstance(ctx context.Context, sqlDb *sql.DB) (m *migrate.Migrate, cleanup func(), err error) { path := "migrations/" + string(common.EnvConfig.DbProvider) source, err := iofs.New(resources.FS, path) if err != nil { - return nil, fmt.Errorf("failed to create embedded migration source: %w", err) + return nil, nil, fmt.Errorf("failed to create embedded migration source: %w", err) } - driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider) + driver, cleanup, err := newMigrationDriver(ctx, sqlDb, common.EnvConfig.DbProvider) if err != nil { - return nil, fmt.Errorf("failed to create migration driver: %w", err) + return nil, nil, fmt.Errorf("failed to create migration driver: %w", err) } - m, err := migrate.NewWithInstance("iofs", source, "pocket-id", driver) + m, err = migrate.NewWithInstance("iofs", source, "pocket-id", driver) if err != nil { - return nil, fmt.Errorf("failed to create migration instance: %w", err) + cleanup() + return nil, nil, fmt.Errorf("failed to create migration instance: %w", err) } - return m, nil + return m, cleanup, nil } // newMigrationDriver creates a database.Driver instance based on the given database provider. -func newMigrationDriver(sqlDb *sql.DB, dbProvider common.DbProvider) (driver database.Driver, err error) { +// The returned cleanup function releases any resources the driver holds and must always be called. +func newMigrationDriver(ctx context.Context, sqlDb *sql.DB, dbProvider common.DbProvider) (driver database.Driver, cleanup func(), err error) { + // Default cleanup is a no-op + cleanup = func() {} + switch dbProvider { case common.DbProviderSqlite: + // The SQLite driver runs statements on the pool directly, so it holds no dedicated connection. driver, err = sqliteMigrate.WithInstance(sqlDb, &sqliteMigrate.Config{ NoTxWrap: true, }) case common.DbProviderPostgres: - driver, err = postgresMigrate.WithInstance(sqlDb, &postgresMigrate.Config{}) + // The Postgres driver checks out a dedicated connection + // Use WithConnection (rather than WithInstance) with a connection we own so cleanup can return it to the pool without closing the shared *sql.DB + // Otherwise the leaked connection makes pgxpool.Close() block on shutdown + var conn *sql.Conn + conn, err = sqlDb.Conn(ctx) + if err != nil { + return nil, cleanup, fmt.Errorf("failed to acquire migration connection: %w", err) + } + cleanup = func() { + // Close the connection + _ = conn.Close() + } + driver, err = postgresMigrate.WithConnection(ctx, conn, &postgresMigrate.Config{}) default: // Should never happen at this point - return nil, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) + return nil, cleanup, fmt.Errorf("unsupported database provider: %s", common.EnvConfig.DbProvider) } if err != nil { - return nil, fmt.Errorf("failed to create migration driver: %w", err) + cleanup() + return nil, func() {}, fmt.Errorf("failed to create migration driver: %w", err) } - return driver, nil + return driver, cleanup, nil } // migrateDatabaseFromGitHub applies database migrations fetched from GitHub to handle downgrades. -func migrateDatabaseFromGitHub(sqlDb *sql.DB, requiredVersion uint, currentVersion uint) error { +func migrateDatabaseFromGitHub(ctx context.Context, sqlDb *sql.DB, requiredVersion uint, currentVersion uint) error { srcURL := "github://pocket-id/pocket-id/backend/resources/migrations/" + string(common.EnvConfig.DbProvider) - driver, err := newMigrationDriver(sqlDb, common.EnvConfig.DbProvider) + driver, cleanup, err := newMigrationDriver(ctx, sqlDb, common.EnvConfig.DbProvider) if err != nil { return fmt.Errorf("failed to create migration driver: %w", err) } + defer cleanup() m, err := migrate.NewWithDatabaseInstance(srcURL, "pocket-id", driver) if err != nil { @@ -127,20 +153,29 @@ func getRequiredMigrationVersion(path string) (uint, error) { return 0, fmt.Errorf("failed to read migration directory: %w", err) } - var maxVersion uint + var maxVersion uint64 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 - } + verString, _, ok := strings.Cut(entry.Name(), "_") + if !ok { + continue + } + version, err := strconv.ParseUint(verString, 10, 64) + if err != nil { + continue + } + + if version > maxVersion { + maxVersion = version } } - return maxVersion, nil + if maxVersion > uint64(^uint(0)) { + // We do not support 32-bit systems + panic("32-bit systems are unsupported") + } + + return uint(maxVersion), nil } diff --git a/backend/internal/utils/jwk/utils.go b/backend/internal/utils/jwk/utils.go index 5debfec4..8e75370a 100644 --- a/backend/internal/utils/jwk/utils.go +++ b/backend/internal/utils/jwk/utils.go @@ -5,21 +5,19 @@ import ( "crypto/ecdsa" "crypto/ed25519" "crypto/elliptic" - "crypto/hmac" "crypto/rand" "crypto/rsa" - "crypto/sha3" "encoding/base64" "encoding/json" "errors" "fmt" - "hash" "io" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/pocket-id/pocket-id/backend/internal/common" + "github.com/pocket-id/pocket-id/backend/internal/utils/crypto" ) const ( @@ -52,13 +50,9 @@ func LoadKeyEncryptionKey(envConfig *common.EnvConfigSchema, instanceID string) } // We need a 256-bit key for encryption with AES-GCM-256 - // We use HMAC with SHA3-256 here to derive the key from the one passed as input // The key is tied to a specific instance of Pocket ID - h := hmac.New(func() hash.Hash { return sha3.New256() }, []byte(envConfig.EncryptionKey)) - fmt.Fprint(h, "pocketid/"+instanceID+"/jwk-kek") - kek = h.Sum(nil) - - return kek, nil + // Note: changing the key derivation or the seed is a breaking change + return crypto.DeriveKey(envConfig.EncryptionKey, "pocketid/"+instanceID+"/jwk-kek") } // ImportRawKey imports a crypto key in "raw" format (e.g. crypto.PrivateKey) into a jwk.Key. diff --git a/backend/internal/utils/jwk/utils_test.go b/backend/internal/utils/jwk/utils_test.go index e25f7277..4a87875c 100644 --- a/backend/internal/utils/jwk/utils_test.go +++ b/backend/internal/utils/jwk/utils_test.go @@ -6,14 +6,31 @@ import ( "crypto/elliptic" "crypto/rand" "crypto/rsa" + "encoding/hex" "testing" "github.com/lestrrat-go/jwx/v3/jwa" "github.com/lestrrat-go/jwx/v3/jwk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + + "github.com/pocket-id/pocket-id/backend/internal/common" ) +func TestLoadKeyEncryptionKeyUsesStableValue(t *testing.T) { + envConfig := &common.EnvConfigSchema{ + EncryptionKey: []byte("test-encryption-key"), + } + + expectedHex := "8e7610a60551627e8bfff3a120513ed18e8176e821214d9c58e6dcb1918f7636" + expected, err := hex.DecodeString(expectedHex) + require.NoError(t, err) + + actual, err := LoadKeyEncryptionKey(envConfig, "instance-123") + require.NoError(t, err) + require.Equal(t, expected, actual) +} + func TestGenerateKey(t *testing.T) { tests := []struct { name string diff --git a/backend/internal/utils/servicerunner.go b/backend/internal/utils/servicerunner.go deleted file mode 100644 index 4ec99618..00000000 --- a/backend/internal/utils/servicerunner.go +++ /dev/null @@ -1,59 +0,0 @@ -package utils - -import ( - "context" - "errors" -) - -// Source: -// https://github.com/ItalyPaleAle/traefik-forward-auth/blob/v3.5.1/pkg/utils/servicerunner.go -// Copyright (c) 2018, Thom Seddon & Contributors Copyright (c) 2023, Alessandro Segala & Contributors -// License: MIT (https://github.com/ItalyPaleAle/traefik-forward-auth/blob/v3.5.1/LICENSE.md) - -// Service is a background service -type Service func(ctx context.Context) error - -// ServiceRunner oversees a number of services running in background -type ServiceRunner struct { - services []Service -} - -// NewServiceRunner creates a new ServiceRunner -func NewServiceRunner(services ...Service) *ServiceRunner { - return &ServiceRunner{ - services: services, - } -} - -// Run all background services -func (r *ServiceRunner) Run(ctx context.Context) error { - ctx, cancel := context.WithCancel(ctx) - defer cancel() - - errCh := make(chan error) - for _, service := range r.services { - go func(service Service) { - // Run the service - rErr := service(ctx) - - // Ignore context canceled errors here as they generally indicate that the service is stopping - if rErr != nil && !errors.Is(rErr, context.Canceled) { - cancel() - errCh <- rErr - return - } - errCh <- nil - }(service) - } - - // Wait for all services to return - errs := make([]error, 0) - for range len(r.services) { - err := <-errCh - if err != nil { - errs = append(errs, err) - } - } - - return errors.Join(errs...) -} diff --git a/backend/internal/utils/servicerunner_test.go b/backend/internal/utils/servicerunner_test.go deleted file mode 100644 index 90ff7775..00000000 --- a/backend/internal/utils/servicerunner_test.go +++ /dev/null @@ -1,145 +0,0 @@ -package utils - -import ( - "context" - "errors" - "testing" - "time" - - "github.com/stretchr/testify/require" -) - -// Source: -// https://github.com/ItalyPaleAle/traefik-forward-auth/blob/v3.5.1/pkg/utils/servicerunner.go -// Copyright (c) 2018, Thom Seddon & Contributors Copyright (c) 2023, Alessandro Segala & Contributors -// License: MIT (https://github.com/ItalyPaleAle/traefik-forward-auth/blob/v3.5.1/LICENSE.md) - -func TestServiceRunner_Run(t *testing.T) { - t.Run("successful services", func(t *testing.T) { - // Create a service that just returns no error after 0.2s - successService := func(ctx context.Context) error { - time.Sleep(200 * time.Millisecond) - return nil - } - - // Create a service runner with two success services - runner := NewServiceRunner(successService, successService) - - // Run the services with a timeout to avoid hanging if something goes wrong - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - // Run should return nil when all services succeed - err := runner.Run(ctx) - require.NoError(t, err) - }) - - t.Run("service with error", func(t *testing.T) { - // Create a service that returns an error - expectedErr := errors.New("service failed") - errorService := func(ctx context.Context) error { - return expectedErr - } - - // Create a service runner with one error service and one success service - successService := func(ctx context.Context) error { - time.Sleep(200 * time.Millisecond) - return nil - } - - runner := NewServiceRunner(errorService, successService) - - // Run the services with a timeout - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - // Run should return the error - err := runner.Run(ctx) - require.Error(t, err) - - // The error should contain our expected error - require.ErrorIs(t, err, expectedErr) - }) - - t.Run("service error cancels others", func(t *testing.T) { - expectedErr := errors.New("boom") - errorService := func(ctx context.Context) error { - return expectedErr - } - waitingService := func(ctx context.Context) error { - <-ctx.Done() - return ctx.Err() - } - - runner := NewServiceRunner(errorService, waitingService) - - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - err := runner.Run(ctx) - require.Error(t, err) - require.ErrorIs(t, err, expectedErr) - }) - - t.Run("context canceled", func(t *testing.T) { - // Create a service that waits until context is canceled - waitingService := func(ctx context.Context) error { - <-ctx.Done() - return ctx.Err() - } - - // Create another service that returns no error quickly - quickService := func(ctx context.Context) error { - return nil - } - - runner := NewServiceRunner(waitingService, quickService) - - // Create a context that we can cancel - ctx, cancel := context.WithCancel(t.Context()) - - // Run the runner in a goroutine - errCh := make(chan error) - go func() { - errCh <- runner.Run(ctx) - }() - - // Cancel the context to trigger service shutdown - cancel() - - // Wait for the runner to finish with a timeout - select { - case err := <-errCh: - require.NoError(t, err, "expected nil error (context.Canceled should be ignored)") - case <-time.After(5 * time.Second): - t.Fatal("test timed out waiting for runner to finish") - } - }) - - t.Run("multiple errors", func(t *testing.T) { - // Create two services that return different errors - err1 := errors.New("error 1") - err2 := errors.New("error 2") - - service1 := func(ctx context.Context) error { - return err1 - } - service2 := func(ctx context.Context) error { - return err2 - } - - runner := NewServiceRunner(service1, service2) - - // Run the services - ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) - defer cancel() - - // Run should join all errors - err := runner.Run(ctx) - require.Error(t, err) - - // Check that both errors are included - require.ErrorIs(t, err, err1) - require.ErrorIs(t, err, err2) - }) -} diff --git a/backend/internal/utils/signals/signal.go b/backend/internal/utils/signals/signal.go deleted file mode 100644 index 8699216b..00000000 --- a/backend/internal/utils/signals/signal.go +++ /dev/null @@ -1,40 +0,0 @@ -package signals - -import ( - "context" - "log/slog" - "os" - "os/signal" - "syscall" -) - -/* -This code is adapted from: -https://github.com/kubernetes-sigs/controller-runtime/blob/8499b67e316a03b260c73f92d0380de8cd2e97a1/pkg/manager/signals/signal.go -Copyright 2017 The Kubernetes Authors. -License: Apache2 (https://github.com/kubernetes-sigs/controller-runtime/blob/8499b67e316a03b260c73f92d0380de8cd2e97a1/LICENSE) -*/ - -var onlyOneSignalHandler = make(chan struct{}) - -// SignalContext returns a context that is canceled when the application receives an interrupt signal. -// A second signal forces an immediate shutdown. -func SignalContext(parentCtx context.Context) context.Context { - close(onlyOneSignalHandler) // Panics when called twice - - ctx, cancel := context.WithCancel(parentCtx) - - sigCh := make(chan os.Signal, 2) - signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) - go func() { - <-sigCh - slog.Info("Received interrupt signal. Shutting down…") - cancel() - - <-sigCh - slog.Warn("Received a second interrupt signal. Forcing an immediate shutdown.") - os.Exit(1) - }() - - return ctx -} diff --git a/backend/internal/utils/sqlite/sqlite_util.go b/backend/internal/utils/sqlite/sqlite_util.go index b30171be..a1c3fde2 100644 --- a/backend/internal/utils/sqlite/sqlite_util.go +++ b/backend/internal/utils/sqlite/sqlite_util.go @@ -6,14 +6,14 @@ import ( "fmt" "strings" - sqlitelib "github.com/glebarez/go-sqlite" "golang.org/x/text/unicode/norm" + "modernc.org/sqlite" ) func RegisterSqliteFunctions() { // Register the `normalize(text, form)` function, which performs Unicode normalization on the text // This is currently only used in migration functions - sqlitelib.MustRegisterDeterministicScalarFunction("normalize", 2, func(ctx *sqlitelib.FunctionContext, args []driver.Value) (driver.Value, error) { + sqlite.MustRegisterDeterministicScalarFunction("normalize", 2, func(ctx *sqlite.FunctionContext, args []driver.Value) (driver.Value, error) { if len(args) != 2 { return nil, errors.New("normalize requires 2 arguments") } diff --git a/backend/internal/utils/testing/actors.go b/backend/internal/utils/testing/actors.go new file mode 100644 index 00000000..00b2a6a2 --- /dev/null +++ b/backend/internal/utils/testing/actors.go @@ -0,0 +1,78 @@ +// This file is only imported by unit tests + +package testing + +import ( + "context" + "net" + "testing" + "time" + + "github.com/italypaleale/francis/components/standalone" + "github.com/italypaleale/francis/host/local" + "github.com/stretchr/testify/require" +) + +// testActorHostPSK is the runtime pre-shared key for the test actor host +// It only needs to be a stable value of at least 32 bytes, since the test host never talks to another host +const testActorHostPSK = "pocket-id-test-actor-host-psk-32bytes" + +// NewActorHostForTest starts a single-host Francis cluster backed by the in-memory provider, runs it, and waits until it is ready to serve invocations +// Pass built-in actors with local.WithBuiltInActor; the host is stopped when the test ends +// The in-memory provider keeps no state on disk, so the test never touches a real database +func NewActorHostForTest(t *testing.T, opts ...local.HostOption) *local.Host { + t.Helper() + + // The defaults come first so callers can register their built-in actors (and override any default) through opts + hostOpts := append( + []local.HostOption{ + local.WithAddress(freeLoopbackAddr(t)), + local.WithRuntimePSKs([]byte(testActorHostPSK)), + local.WithStandaloneMemoryProvider(standalone.StandaloneMemoryOptions{}), + local.WithShutdownGracePeriod(time.Second), + }, + opts..., + ) + + h, err := local.NewHost(hostOpts...) + require.NoError(t, err) + + // Run the host in the background and stop it when the test ends + ctx, cancel := context.WithCancel(context.Background()) + errCh := make(chan error, 1) + go func() { + errCh <- h.Run(ctx) + }() + + t.Cleanup(func() { + cancel() + <-errCh + }) + + // Wait until the host is ready before returning, so callers can invoke actors immediately + select { + case <-h.Ready(): + case err = <-errCh: + t.Fatalf("actor host stopped before becoming ready: %v", err) + case <-time.After(10 * time.Second): + t.Fatal("timed out waiting for the actor host to become ready") + } + + return h +} + +// freeLoopbackAddr reserves a free loopback port and returns its address +// The port is released before returning, so the actor host can bind it +func freeLoopbackAddr(t *testing.T) string { + t.Helper() + + var lc net.ListenConfig + lis, err := lc.Listen(t.Context(), "tcp", "127.0.0.1:0") + require.NoError(t, err) + + addr := lis.Addr().String() + err = lis.Close() + require.NoError(t, err) + + return addr +} diff --git a/backend/internal/utils/testing/database.go b/backend/internal/utils/testing/database.go index 4cc6c5e6..1842c067 100644 --- a/backend/internal/utils/testing/database.go +++ b/backend/internal/utils/testing/database.go @@ -8,10 +8,10 @@ import ( _ "github.com/golang-migrate/migrate/v4/source/file" - "github.com/glebarez/sqlite" "github.com/golang-migrate/migrate/v4" sqliteMigrate "github.com/golang-migrate/migrate/v4/database/sqlite3" "github.com/golang-migrate/migrate/v4/source/iofs" + "github.com/libtnb/sqlite" "github.com/stretchr/testify/require" "gorm.io/gorm" "gorm.io/gorm/logger"