diff --git a/api/go.mod b/api/go.mod index 3eaf5cd5a..1f305a110 100644 --- a/api/go.mod +++ b/api/go.mod @@ -1,14 +1,14 @@ module github.com/NdoleStudio/httpsms -go 1.25.8 +go 1.26.0 require ( cloud.google.com/go/cloudtasks v1.19.0 cloud.google.com/go/compute/metadata v0.9.0 cloud.google.com/go/storage v1.64.0 firebase.google.com/go v3.13.0+incompatible - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 - github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.36.0 + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 + github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0 github.com/NdoleStudio/go-otelroundtripper v0.0.15 github.com/NdoleStudio/lemonsqueezy-go v1.3.2 github.com/NdoleStudio/plunk-go v0.0.2 @@ -23,7 +23,7 @@ require ( github.com/dustin/go-humanize v1.0.1 github.com/gertd/go-pluralize v0.2.1 github.com/go-hermes/hermes/v2 v2.6.2 - github.com/gofiber/contrib/v3/otel v1.2.4 + github.com/gofiber/contrib/v3/otel v1.2.5 github.com/gofiber/contrib/v3/swaggo v1.0.8 github.com/gofiber/fiber/v3 v3.5.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -39,24 +39,24 @@ require ( github.com/patrickmn/go-cache v2.1.0+incompatible github.com/pkg/errors v0.9.1 github.com/pusher/pusher-http-go/v5 v5.1.1 - github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 - github.com/redis/go-redis/v9 v9.21.0 + github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 + github.com/redis/go-redis/v9 v9.22.0 github.com/rs/zerolog v1.35.1 github.com/stretchr/testify v1.12.1 github.com/swaggo/swag v1.16.6 github.com/thedevsaddam/govalidator v1.9.10 github.com/uptrace/uptrace-go v1.43.0 github.com/xuri/excelize/v2 v2.11.0 - go.mongodb.org/mongo-driver/v2 v2.8.2 + go.mongodb.org/mongo-driver/v2 v2.9.1 go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708 go.opentelemetry.io/otel v1.46.0 - go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 - go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 go.opentelemetry.io/otel/metric v1.46.0 go.opentelemetry.io/otel/sdk v1.46.0 go.opentelemetry.io/otel/sdk/metric v1.46.0 go.opentelemetry.io/otel/trace v1.46.0 - golang.org/x/sync v0.22.0 + golang.org/x/sync v0.23.0 google.golang.org/api v0.295.0 google.golang.org/protobuf v1.36.12 gorm.io/driver/postgres v1.6.0 @@ -91,7 +91,7 @@ require ( github.com/ClickHouse/ch-go v0.72.0 // indirect github.com/ClickHouse/clickhouse-go/v2 v2.46.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 // indirect - github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 // indirect + github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Masterminds/goutils v1.1.1 // indirect github.com/PuerkitoBio/goquery v1.12.0 // indirect @@ -113,27 +113,27 @@ require ( github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect github.com/go-logr/stdr v1.2.2 // indirect - github.com/go-openapi/jsonpointer v0.24.0 // indirect - github.com/go-openapi/jsonreference v0.21.6 // indirect - github.com/go-openapi/spec v0.22.6 // indirect - github.com/go-openapi/swag/conv v0.27.0 // indirect - github.com/go-openapi/swag/jsonname v0.27.0 // indirect - github.com/go-openapi/swag/jsonutils v0.27.0 // indirect - github.com/go-openapi/swag/loading v0.27.0 // indirect - github.com/go-openapi/swag/stringutils v0.27.0 // indirect - github.com/go-openapi/swag/typeutils v0.27.0 // indirect - github.com/go-openapi/swag/yamlutils v0.27.0 // indirect + github.com/go-openapi/jsonpointer v1.0.0 // indirect + github.com/go-openapi/jsonreference v1.0.0 // indirect + github.com/go-openapi/spec v0.22.9 // indirect + github.com/go-openapi/swag/conv v0.28.0 // indirect + github.com/go-openapi/swag/jsonutils v0.28.0 // indirect + github.com/go-openapi/swag/loading v0.28.0 // indirect + github.com/go-openapi/swag/pools v0.28.0 // indirect + github.com/go-openapi/swag/stringutils v0.28.0 // indirect + github.com/go-openapi/swag/typeutils v0.28.0 // indirect + github.com/go-openapi/swag/yamlutils v0.28.0 // indirect github.com/go-sql-driver/mysql v1.10.0 // indirect github.com/goccy/go-json v0.10.6 // indirect - github.com/gofiber/schema v1.8.4 // indirect - github.com/gofiber/utils/v2 v2.4.2 // indirect + github.com/gofiber/schema v1.8.6 // indirect + github.com/gofiber/utils/v2 v2.5.1 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/s2a-go v0.1.9 // indirect github.com/googleapis/enterprise-certificate-proxy v0.3.20 // indirect github.com/googleapis/gax-go/v2 v2.24.0 // indirect github.com/gorilla/css v1.0.1 // indirect - github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-version v1.9.0 // indirect github.com/huandu/xstrings v1.5.0 // indirect @@ -144,7 +144,7 @@ require ( github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/now v1.1.5 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.19.2 // indirect + github.com/klauspost/compress v1.20.0 // indirect github.com/mattn/go-colorable v0.1.15 // indirect github.com/mattn/go-isatty v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect @@ -153,12 +153,13 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/molecule-man/go-brrr v1.0.1 // indirect github.com/olekukonko/tablewriter v1.1.4 // indirect github.com/paulmach/orb v0.13.0 // indirect github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.27 // indirect github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 // indirect - github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 // indirect + github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 // indirect github.com/richardlehane/mscfb v1.0.7 // indirect github.com/richardlehane/msoleps v1.0.6 // indirect github.com/segmentio/asm v1.2.1 // indirect @@ -169,7 +170,7 @@ require ( github.com/tiendc/go-deepcopy v1.7.2 // indirect github.com/tinylib/msgp v1.6.4 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.73.0 // indirect + github.com/valyala/fasthttp v1.74.0 // indirect github.com/vanng822/css v1.0.1 // indirect github.com/vanng822/go-premailer v1.34.0 // indirect github.com/xdg-go/pbkdf2 v1.0.0 // indirect @@ -181,31 +182,31 @@ require ( go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib v1.46.0 // indirect go.opentelemetry.io/contrib/detectors/gcp v1.44.0 // indirect - go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 // indirect go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 // indirect go.opentelemetry.io/contrib/processors/minsev v0.16.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect - go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 // indirect go.opentelemetry.io/otel/log v0.20.0 // indirect go.opentelemetry.io/otel/sdk/log v0.20.0 // indirect - go.opentelemetry.io/proto/otlp v1.10.0 // indirect + go.opentelemetry.io/proto/otlp v1.11.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect go.yaml.in/yaml/v3 v3.0.5 // indirect - golang.org/x/crypto v0.55.0 // indirect - golang.org/x/mod v0.38.0 // indirect - golang.org/x/net v0.58.0 // indirect + golang.org/x/crypto v0.57.0 // indirect + golang.org/x/mod v0.41.0 // indirect + golang.org/x/net v0.59.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.47.0 // indirect - golang.org/x/text v0.41.0 // indirect + golang.org/x/sys v0.48.0 // indirect + golang.org/x/text v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/tools v0.49.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect google.golang.org/grpc v1.83.2 // indirect gorm.io/driver/clickhouse v0.7.0 // indirect diff --git a/api/go.sum b/api/go.sum index 7040f1fe7..809da4525 100644 --- a/api/go.sum +++ b/api/go.sum @@ -40,12 +40,14 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.33.0/go.mod h1:pJTkW8hEUIIi3Pf65lPZOnn4Y81yCllX6IWk2jNXdkM= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0 h1:HldzheTs05E3ybqSitI/wHaof6+XERRudgZLjYbs3eE= github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.60.0/go.mod h1:evkqaSczW9g2BQm1veCtgNhJ4wCCsRrOsSgNIn9LHQk= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.36.0 h1:gBlHWOs3hqcN7cqRbyrvI6q1QwSZHyp76FajMUzgdpQ= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.36.0/go.mod h1:LmYRYS/wzrxoHi0OKmwhCujGpXFmab6c9xCyP58Hh9U= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0 h1:Fx8NtDCmKH4ML2hUkPz4Dq250903vRDojMjVCDKwQuc= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.60.0/go.mod h1:V9g30lTKzfUsEW+gpWssck6u9IhARajmipodImLLcwI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0 h1:Oblia1QXBJlM/wOY9ARRUtsXdDYiMCzk3eCMikqoLbI= -github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.60.0/go.mod h1:SRAbhyZ4R4FagHMM9VtRgSY/lheRoht2fKelZXQUenk= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0 h1:5y2LR2pripbccOte6BpZ+guv5O5W0OQIfKvUYZrGaAQ= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.62.0/go.mod h1:b3f7EifFRqkVt142USoGIP62uUeZm7ALCPrs1ZjWXYo= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0 h1:Smjr+Zjtrz0GEI2AxvHxew0V1vkDlvcm+0hyArVNkKY= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/trace v1.38.0/go.mod h1:Ov3/u9CUq4/paK8CRyJyvGY7N5YWeneZVLLtiL2sx8E= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0 h1:tRsXhJnn0w0TNwJpVrB94wsrimzjWKFzdQlUhRdNUzc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.62.0/go.mod h1:ifhe5teRNcbxg9p353DhOkOgczzL/IBLYFqT1cm1zpM= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0 h1:aPMa5qZU9dp4fU34o+jQgVIPFIOi20oxYg8TLigmZLg= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.62.0/go.mod h1:sg6Wqbl0BKKZ5zKnQj2q0qic/3ziF894SCx1LpbX37Y= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= @@ -137,29 +139,29 @@ github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= -github.com/go-openapi/jsonpointer v0.24.0 h1:AA6mCjHYHmZ+1RU2Js089EaOK/iwXXNwQsTgnsTha2M= -github.com/go-openapi/jsonpointer v0.24.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= -github.com/go-openapi/jsonreference v0.21.6 h1:NZ5nGfnaM1n4I43Xjm1e5/M2GjOwQwndQz22uhxwD+Y= -github.com/go-openapi/jsonreference v0.21.6/go.mod h1:xzbgtQ3ZbWxvET3AxdzCJlJt6vkovbf+IfSPJjD0tUY= -github.com/go-openapi/spec v0.22.6 h1:Tyy1pLaNCM8GBCFLoGYLonjJi6zykqyLCjXLc19ZPic= -github.com/go-openapi/spec v0.22.6/go.mod h1:HZvTHat+iH0PALQRWhrqIHtU/PEqxqd89fu0MxGlMeM= -github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyrCM= -github.com/go-openapi/swag/conv v0.27.0 h1:EKOH4feXrvdo8DbSsXSAqRT8fz1epEnS5O2IfXUOzE8= -github.com/go-openapi/swag/conv v0.27.0/go.mod h1:pfiv0uKQTbaGApk8Zs/lZV3uSjmSpa2FO1y183YngN8= -github.com/go-openapi/swag/jsonname v0.27.0 h1:4QVB//CKOdE8IOiBg19JNY2wfDS48MhesIquYBy2rUE= -github.com/go-openapi/swag/jsonname v0.27.0/go.mod h1:I1YsyvvhBuZsFXSW6I7ODfdyq13p7hDil//1T9/pFFk= -github.com/go-openapi/swag/jsonutils v0.27.0 h1:VYtd9jEQYeU4j8q5vdn5KWotF4vKywhGdMBrALtAsfE= -github.com/go-openapi/swag/jsonutils v0.27.0/go.mod h1:U7pb8AGuwhok3RDicHeHwSG4L3PXSq6PAL98Aon632g= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0 h1:+d7C7Ur/SsGg/UZ9G0JEovnfRqtMNZCJQGKc2h/ojoE= -github.com/go-openapi/swag/jsonutils/fixtures_test v0.27.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= -github.com/go-openapi/swag/loading v0.27.0 h1:s8DA9aPEdFH6OluHUYUn3DnIuoTdyWs9RwffXBUfyeI= -github.com/go-openapi/swag/loading v0.27.0/go.mod h1:VOz+Jg6UGGywcmRvYsI4fvtp+bd7NfioseGEPleYdA4= -github.com/go-openapi/swag/stringutils v0.27.0 h1:Of7w/HljWsNZvuxsUAnw3n+hCOyI6HLJOxW2kQRAxio= -github.com/go-openapi/swag/stringutils v0.27.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= -github.com/go-openapi/swag/typeutils v0.27.0 h1:aCf4MSGo8NLwZP8Q6t32DWLJSvl/WwNqgmEG+xJ6v2o= -github.com/go-openapi/swag/typeutils v0.27.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= -github.com/go-openapi/swag/yamlutils v0.27.0 h1:bQ6eAMil5X9tdcf7dMn4t15alzG6jddnrKPuKa/zxKM= -github.com/go-openapi/swag/yamlutils v0.27.0/go.mod h1:yRfIo7qqVkmJRQjX8exjA3AfcI8rH1KDNPsTparoCv4= +github.com/go-openapi/jsonpointer v1.0.0 h1:kR9tHqY0CtZaOPVFm622dPVNhrvYpwr4uCxgL3h1H8s= +github.com/go-openapi/jsonpointer v1.0.0/go.mod h1:Z3rw7dWu1p9IgitXCFamSlA5lmDiklEB6vkaxcNZW5Y= +github.com/go-openapi/jsonreference v1.0.0 h1:jlmTr6torcd1YgDQvSfNmRtKzYDO4FGBkrAdlAVWnpY= +github.com/go-openapi/jsonreference v1.0.0/go.mod h1:jtwdyGbJk0Xhe5Y+rwtglQP6Sb1WZST4rT32LWB+sv0= +github.com/go-openapi/spec v0.22.9 h1:/vKIFDcGKp0ktZWGbym/tJEWbk6/XOEmAVU0kqKMH+w= +github.com/go-openapi/spec v0.22.9/go.mod h1:b/mNUYIOQOyIiUzUzXEE8xzyZqf93KvM9hQGP91yfl0= +github.com/go-openapi/swag v0.28.0 h1:xkgbOSKj6DZziNpyqRRAOt3GJGtgjgsd2RoyT30VWuw= +github.com/go-openapi/swag/conv v0.28.0 h1:GtqqbyFe7vR5Y7ehxG9W6/OvrSFdf1OLeTGp40TqxH8= +github.com/go-openapi/swag/conv v0.28.0/go.mod h1:mbUE+mzctnhxi864m0Q07SpN8OowD9JhxmxuYvZZD/k= +github.com/go-openapi/swag/jsonutils v0.28.0 h1:YIch6FwO7RXzeAnbO8Tu7dWBZeUEH+4nA0HXltVTnv4= +github.com/go-openapi/swag/jsonutils v0.28.0/go.mod h1:CYM3WlTUcagR2ZoHdz54di/cbBqt82tuxuXgAjxw+mg= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0 h1:qV+VVUAx5Oro8WjVWpZeql7YReTKhT4smR4zhcOQZr0= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.28.0/go.mod h1:mofwUWx70wvskwESqRJ//k/9kURmCgyJl5m5Ppoh5kY= +github.com/go-openapi/swag/loading v0.28.0 h1:td8QZdZC9MIYGGSnSPKShKiK22I2tU5UQvuUhIBPRLU= +github.com/go-openapi/swag/loading v0.28.0/go.mod h1:rXB0QiQX5mMveXEA7ouM4KiiM9jVJe4K6BVbwhD1M4k= +github.com/go-openapi/swag/pools v0.28.0 h1:HPMZWSAfce3rdVTFcjFiCIBtDg9h4x2QlRrHipwhxeU= +github.com/go-openapi/swag/pools v0.28.0/go.mod h1:kVQefhSK5RWuRe7BXsL8htgBPAMpN7HDGpGEknqugeE= +github.com/go-openapi/swag/stringutils v0.28.0 h1:ixsc9iYgDPubHL/8nSkbnryEHpD2VRlBMLKpQyPXcDU= +github.com/go-openapi/swag/stringutils v0.28.0/go.mod h1:lzRN95CxXmA03XcDWHLOb6nOMcxCqR5rGY0lOgsfRoM= +github.com/go-openapi/swag/typeutils v0.28.0 h1:nRBKSBXjDgf01VDPB3fWeD9nQuhCOVeIYAkUx2tbkyY= +github.com/go-openapi/swag/typeutils v0.28.0/go.mod h1:Srm0xFNRZ1Y+vCxJclo5qzx8aj+1pAKda/YfFPrG0dQ= +github.com/go-openapi/swag/yamlutils v0.28.0 h1:TV3JXH6DS46KUroDtMLAYHGkdWf5VDq3wVWFirmzROY= +github.com/go-openapi/swag/yamlutils v0.28.0/go.mod h1:x0q/yndZHEgk9Rx3DyDqzFUmHy55KTvIZldvF2dTJXs= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0 h1:gGHwAJ0R/5jU8BEGDbfRNR3hL68dAVi84WuOApp29B0= github.com/go-openapi/testify/enable/yaml/v2 v2.6.0/go.mod h1:tY+St1SGq4NFl0QIqdTY4aEdbChAHxhyB77XQi9iJCo= github.com/go-openapi/testify/v2 v2.6.0 h1:5PKH2HE7YJ/LuRPQGvSxBRlFXNQhSetBLlGAgUEu3ug= @@ -168,16 +170,16 @@ github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxo github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= -github.com/gofiber/contrib/v3/otel v1.2.4 h1:wID72pBdMhWcoUzmj4T8OYH2mVabDHtw/hZG5BmRvWQ= -github.com/gofiber/contrib/v3/otel v1.2.4/go.mod h1:aK0uuKzhSJ8X6lN6I7AlDCVoAr4ES0ZjkLl0Cny/WXE= +github.com/gofiber/contrib/v3/otel v1.2.5 h1:1zvCRB5Qreey12RCSPZUlawcTvYb3wb4QRKzJXk+V9M= +github.com/gofiber/contrib/v3/otel v1.2.5/go.mod h1:YPPFGXIr/SLAVI2+u0Kq9ET6C9Hwdfh8CXRZbQZIDs0= github.com/gofiber/contrib/v3/swaggo v1.0.8 h1:11YmMVbkMO7zv6YR0gdNfRWcs5ZVUkDpR21AhjhbK00= github.com/gofiber/contrib/v3/swaggo v1.0.8/go.mod h1:7jyBZxqQ4MjOxIidOvLQ9jNAxNk05yKpid40OYb3pLE= github.com/gofiber/fiber/v3 v3.5.0 h1:dk7TOUH6DXJGtOLsN2XEG+0ZML7cznzHILTVozbNEK8= github.com/gofiber/fiber/v3 v3.5.0/go.mod h1:GOVDTW+gjJvfe0iJyVujbQ1Lnx+JUjFySJRI/9/xX/w= -github.com/gofiber/schema v1.8.4 h1:ctANnOE2uXft17l5cw78qYqoLt2nfZGRgZ2QUugefFQ= -github.com/gofiber/schema v1.8.4/go.mod h1:JxOlqaEBpuyGKBLI9wY8BAsnWt9z+cFGLaijlAF/IF0= -github.com/gofiber/utils/v2 v2.4.2 h1:OA4LAEG3zKkPwFU7Jippxdk//N5hKMU12qXSCd0C4lQ= -github.com/gofiber/utils/v2 v2.4.2/go.mod h1:AGosyllO+RVdfXyyKoUp91D0qj86BuknRN4I6mciqe8= +github.com/gofiber/schema v1.8.6 h1:xVlbSg3vsyKVlfzRBps2tJ7pHx1d8mSgiaJye6Lb3xs= +github.com/gofiber/schema v1.8.6/go.mod h1:Cxh3loYVG3FLA7FzW4C5G83pL/pmCmAnuaIJ5y2HwkU= +github.com/gofiber/utils/v2 v2.5.1 h1:Yrt/ljbfsgnDSHymoMvVX5PFrb9kCV/OJnDu6sYuT4A= +github.com/gofiber/utils/v2 v2.5.1/go.mod h1:5GyHTNQA1I6Qwlw7BWxQsZl8iuFS4yPjLFck+wYR304= github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw= github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= @@ -205,8 +207,8 @@ github.com/googleapis/gax-go/v2 v2.24.0 h1:myMaPYyF9MecEmvQqMqomIwn9t/4KCZN9qnws github.com/googleapis/gax-go/v2 v2.24.0/go.mod h1:IaTHBDd7NHxSCiu0vEs8pQZu4dGZrWwuSoxCnk16OFM= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -243,8 +245,8 @@ 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/jszwec/csvutil v1.10.0 h1:upMDUxhQKqZ5ZDCs/wy+8Kib8rZR8I8lOR34yJkdqhI= github.com/jszwec/csvutil v1.10.0/go.mod h1:/E4ONrmGkwmWsk9ae9jpXnv9QT8pLHEPcCirMFhxG9I= -github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= -github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA= +github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI= github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -270,6 +272,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/molecule-man/go-brrr v1.0.1 h1:cEjgx8hgNw6UGdhQ94SPDbPkKuRbkUcxBO3IzbGpA/o= +github.com/molecule-man/go-brrr v1.0.1/go.mod h1:7ybW6/7gA3oKY45jOfVNjSJDtrr6ea4tzbsTkjmQDC4= github.com/nyaruka/phonenumbers v1.8.1 h1:2K9YMQuv1dCGqjjzB1DwmdCe89khT4KPBQb2CxAMMlU= github.com/nyaruka/phonenumbers v1.8.1/go.mod h1:fsKPJ70O9JetEA4ggnJadYTFWwtGPvu/lETTXNXq6Cs= github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc= @@ -297,12 +301,12 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pusher/pusher-http-go/v5 v5.1.1 h1:ZLUGdLA8yXMvByafIkS47nvuXOHrYmlh4bsQvuZnYVQ= github.com/pusher/pusher-http-go/v5 v5.1.1/go.mod h1:Ibji4SGoUDtOy7CVRhCiEpgy+n5Xv6hSL/QqYOhmWW8= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0 h1:jsV3tyMeJrEoc2f3EhNf7qoBW3NEZW7l/4ziT3M+OJI= -github.com/redis/go-redis/extra/rediscmd/v9 v9.21.0/go.mod h1:e5t17bY9cEpVV+xw2U7jsPOKkXBtL5IQmNVABShnHUk= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0 h1:36qq3rbF2If2CP0zGHHF8o/4XDluErn6DD0c9/L2iNI= -github.com/redis/go-redis/extra/redisotel/v9 v9.21.0/go.mod h1:7y2cVB/LXXLHqHOO2jCVzBqimIQk1w7Rp9WSpyVY/o8= -github.com/redis/go-redis/v9 v9.21.0 h1:FPBE4hhbAke+TLmcY3WkpbDffJEomdqPn3HYiqAtL9E= -github.com/redis/go-redis/v9 v9.21.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0 h1:MQPzEEnpD0BMPufBLABnMYLJVwM7xi7vZ+srO8Nr0s8= +github.com/redis/go-redis/extra/rediscmd/v9 v9.22.0/go.mod h1:eve0JFcLRwFVj3RA85rrrV5+UJ+K9LDyU7kf2UdSueM= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0 h1:t5ul1Gl0o1rYQj5f5bK12G9xcg1niq2ON4yZFjvy1kA= +github.com/redis/go-redis/extra/redisotel/v9 v9.22.0/go.mod h1:hcS9L2RBBjYXkrfSOF26ZGejgo+yOC+28ZD2fkk3sGs= +github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0= +github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4= github.com/richardlehane/mscfb v1.0.7 h1:oeoiM0WE79vHwE8RpIYYvIAc8ajTH2mb6UZm55/+EB0= github.com/richardlehane/mscfb v1.0.7/go.mod h1:pe0+IUIc0AHh0+teNzBlJCtSyZdFOGgV4ZK9bsoV+Jo= github.com/richardlehane/msoleps v1.0.6 h1:9BvkpjvD+iUBalUY4esMwv6uBkfOip/Lzvd93jvR9gg= @@ -313,8 +317,8 @@ github.com/rs/zerolog v1.35.1 h1:m7xQeoiLIiV0BCEY4Hs+j2NG4Gp2o2KPKmhnnLiazKI= github.com/rs/zerolog v1.35.1/go.mod h1:EjML9kdfa/RMA7h/6z6pYmq1ykOuA8/mjWaEvGI+jcw= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= -github.com/shamaton/msgpack/v3 v3.2.0 h1:1q2Ms+MWmuRju+PuDMSFDB7p7621npeX4zprJN5Zck8= -github.com/shamaton/msgpack/v3 v3.2.0/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= +github.com/shamaton/msgpack/v3 v3.2.2 h1:lZITRsXLQkdJ9J2qvh6gjGpWxci/dwuiUf+jm9AudJM= +github.com/shamaton/msgpack/v3 v3.2.2/go.mod h1:sgBYvEiyz8JR1NC3yGRoPVME9xXovpnh3l/plW1nfRo= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= @@ -352,8 +356,8 @@ github.com/uptrace/uptrace-go v1.43.0 h1:5QuCdyFJdWUEXx6Fr6sYfezdgO6n6lnkOvUTLly github.com/uptrace/uptrace-go v1.43.0/go.mod h1:ehDTIdtBSolg4Z0CCvg1C8yR6VX1YFDqBcg2KmsXWn0= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= -github.com/valyala/fasthttp v1.73.0 h1:ocTOORnBWtJ+P8t/6wAjdkchMzdfHmWx2VD/DPbgZ7s= -github.com/valyala/fasthttp v1.73.0/go.mod h1:EtXQDHaR+5P18p8wqDRFpUhxr108Ga9mXvVJXHRrN2k= +github.com/valyala/fasthttp v1.74.0 h1:wMS9fnO2QTALozYx5pId2Vi7ZwU/epUkY8i/KPWCHoU= +github.com/valyala/fasthttp v1.74.0/go.mod h1:3ARmLamUcw7ElxVtC8PXaGzQ6VEuvnetlkrwIklQBSE= github.com/vanng822/css v1.0.1 h1:10yiXc4e8NI8ldU6mSrWmSWMuyWgPr9DZ63RSlsgDw8= github.com/vanng822/css v1.0.1/go.mod h1:tcnB1voG49QhCrwq1W0w5hhGasvOg+VQp9i9H1rCM1w= github.com/vanng822/go-premailer v1.34.0 h1:CW7RUnjCfXrkuCbgC2wi/Cub7IwKslJWD/OkIBlcQUk= @@ -381,8 +385,8 @@ github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE= github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= -go.mongodb.org/mongo-driver/v2 v2.8.2 h1:b6o2m7zL8g2URuO8urBedAylxojybKXNZTxgkOcl+2w= -go.mongodb.org/mongo-driver/v2 v2.8.2/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.mongodb.org/mongo-driver/v2 v2.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ= +go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib v1.46.0 h1:PVrao7T/FJZ814/+QbMcIMIIpdjQXC3pi3V11+6DT40= @@ -391,10 +395,10 @@ go.opentelemetry.io/contrib/detectors/gcp v1.44.0 h1:NmLfL734pJhM0JKaYd2Y28+nY9d go.opentelemetry.io/contrib/detectors/gcp v1.44.0/go.mod h1:tNAsgd8avTGke1+MndXlU5Cru4PQ9Ai/cCNWQv/ZJ/s= go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708 h1:s5k/FTgmE4Mj2jFG38Dj5TQgxDRMG92fRK3puTdMHew= go.opentelemetry.io/contrib/instrumentation/go.mongodb.org/mongo-driver/v2/mongo/otelmongo v0.0.0-20260624193928-df9c7a836708/go.mod h1:VgtIsrVXKo8KmG2sFvQazHcQ98P2FHQ+ZePXtpTPjqs= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0 h1:oECp5f+hN7nkwjU/8BxQ/q23bGPb8FIrD839owX222E= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.70.0/go.mod h1:DqEFwLumhzMBDQv9PcWbyoDxHI/4lAk6CM4nJBH39sc= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0 h1:LMuyCAyfalSjDyjdC65nK6N0zoTT63+E/u95X0JovZI= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.70.0/go.mod h1:085m8qbm4hgc8rZWGDEa4vmyyo2c3nPxUslYUKUIU04= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0 h1:MtkMsuRo3zEXTTMALfyrszwCDZTkB6wolyPjbwFAdq0= go.opentelemetry.io/contrib/instrumentation/runtime v0.69.0/go.mod h1:FYTxnpsm+UPD0erZNq20GvnM8T2YQHiHtT2vokdpoac= go.opentelemetry.io/contrib/processors/minsev v0.16.1 h1:DYL02u57VGQjrG8c09i6Bx5R74h4XxLj75wEk5h8/DA= @@ -405,14 +409,16 @@ go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0 h1:owlhcJ3QO go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.20.0/go.mod h1:MP4eemTiI9zC8fgg+DYynhYDYf3ba72S376TvP+Ye0Q= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0 h1:RuynHbfU8JUEw7DyONgkVYg2SVtsoF28y0LGIr69jgA= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.44.0/go.mod h1:qZF+/lBs71APw8mlnEZcqZHMzqrYrsFiJOv83lX1OGo= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0 h1:AP23h/mFgb/lc7tdck1Kfn9qxsM8TAeNPCU5C3pzaps= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.46.0/go.mod h1:K4EqCe1b4kGk5WR690ntg9LaBfsPoV32FwthbyoptuA= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0 h1:KrC1YrQeSt46ITMWAbgQx1M1eV1/1TKzttrBzymPmss= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.46.0/go.mod h1:zDSEzoEqsOrgBeGvH66KRgxh90VonFyJqBHA0Pk3+rM= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0 h1:hqxVTu/GtBF+vJ8d1fzW7fRxZFvgoDjWcxwwCaFDYpU= go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.44.0/go.mod h1:z5fVEF4X5v0ESvlJqBrrFlBVoj5EQuefZpzsu7R+x5Q= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= -go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0 h1:lsA/S1bxgdbyFGkTj+3meEdJ6ADVU7QoFstV6MXgE68= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.45.0/go.mod h1:L7u+MirGoB1bjeLH66+xDykF4RC8C3RN7lIFpBiewUo= go.opentelemetry.io/otel/log v0.20.0 h1:/5i0vuHxCLWUfChWG41K9wkM0jafruPw9NU1/RCJirs= go.opentelemetry.io/otel/log v0.20.0/go.mod h1:wOcMcjsZpG8x7Bak7IhSi/lg8wscV2C1VdrKCLPlt0E= go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8= @@ -429,8 +435,8 @@ go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3 go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4= go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c= go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI= -go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= -go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= +go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk= +go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= @@ -444,48 +450,48 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200709230013-948cd5f35899/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M= -golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis= +golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M= +golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA= golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/mod v0.41.0 h1:qJmnOUb4YB+FsEuM3HcWucdZASCPGhsX6uljO6pog0c= +golang.org/x/mod v0.41.0/go.mod h1:Ek9pY8RKWXwsWvd3rQiHYtMqkjSUV+s1Rj7j4H5Ur6o= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= -golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues= +golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= -golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk= +golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= -golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo= +golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= -golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= -golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= @@ -496,8 +502,8 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d h1:C9v1o0/4quuhOAfmRXA2j+we0PqZIp8traLdeogF3Ms= google.golang.org/genproto v0.0.0-20260715232425-e75dac1f907d/go.mod h1:Wz2wFJntZFmLGo7pLDXZ3wYk5hyc0Mb+SkHhDDXT+lU= -google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d h1:QwnJwPte4XXAkhPu26LTDIahnsMSUV0kK8HkxbC+Pc4= -google.golang.org/genproto/googleapis/api v0.0.0-20260715232425-e75dac1f907d/go.mod h1:WRrQ7/7N19PypuT0fxLOL5Lq0waoiRri4FbtHDEKrGE= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI= +google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI= google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4= google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA= google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= diff --git a/api/pkg/services/emulator_fcm_client.go b/api/pkg/services/emulator_fcm_client.go index 6b890a14a..09dce8e54 100644 --- a/api/pkg/services/emulator_fcm_client.go +++ b/api/pkg/services/emulator_fcm_client.go @@ -11,6 +11,7 @@ import ( "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" + "github.com/google/uuid" ) // EmulatorFCMClient sends FCM messages to the phone emulator via HTTP. @@ -50,7 +51,7 @@ type emulatorFCMResponse struct { } // Send sends a message to the emulator's FCM endpoint. -func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *EmulatorFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { payload := &emulatorFCMRequest{ Message: &emulatorFCMMessage{ Token: message.Token, diff --git a/api/pkg/services/fcm_client.go b/api/pkg/services/fcm_client.go index 6b60b8247..8139c808d 100644 --- a/api/pkg/services/fcm_client.go +++ b/api/pkg/services/fcm_client.go @@ -4,12 +4,15 @@ import ( "context" "firebase.google.com/go/messaging" + "github.com/google/uuid" ) // FCMClient sends Firebase-compatible messages through a phone notification transport. type FCMClient interface { // Send sends a message and returns the transport's delivery identifier on success. - Send(ctx context.Context, message *messaging.Message) (string, error) + // phoneID identifies the receiving phone (the notification's target) and is used by HTTP + // adapter transports to sign the request. + Send(ctx context.Context, message *messaging.Message, phoneID uuid.UUID) (string, error) } // FirebaseFCMClient wraps the real Firebase messaging.Client. @@ -23,6 +26,6 @@ func NewFirebaseFCMClient(client *messaging.Client) *FirebaseFCMClient { } // Send sends a message via the real Firebase SDK. -func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message) (string, error) { +func (c *FirebaseFCMClient) Send(ctx context.Context, message *messaging.Message, _ uuid.UUID) (string, error) { return c.client.Send(ctx, message) } diff --git a/api/pkg/services/http_notification_sender.go b/api/pkg/services/http_notification_sender.go index 9477611af..5fc115823 100644 --- a/api/pkg/services/http_notification_sender.go +++ b/api/pkg/services/http_notification_sender.go @@ -5,6 +5,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/url" @@ -14,6 +15,8 @@ import ( "github.com/NdoleStudio/httpsms/pkg/telemetry" "github.com/NdoleStudio/stacktrace" "github.com/avast/retry-go/v5" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" ) const ( @@ -21,6 +24,8 @@ const ( notificationHTTPAttempts = 3 notificationHTTPTimeout = 5 * time.Second notificationHTTPRetryDelay = 250 * time.Millisecond + notificationJWTIssuer = "api.httpsms.com" + notificationJWTValidity = 10 * time.Minute ) // HTTPNotificationSender sends FCM-compatible gateway notifications to HTTPS adapters. @@ -62,6 +67,7 @@ func newHTTPNotificationSenderWithRetrier( func (sender *HTTPNotificationSender) Send( ctx context.Context, message *messaging.Message, + phoneID uuid.UUID, ) (string, error) { if message == nil { return "", sender.notificationError("", "notification message is nil") @@ -78,8 +84,13 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "cannot encode notification") } + authToken, err := sender.getAuthToken(endpoint, phoneID) + if err != nil { + return "", sender.notificationError(hostname, "cannot generate notification auth token") + } + err = sender.retrier.Do(func() error { - return sender.deliver(ctx, endpoint, body) + return sender.deliver(ctx, endpoint, body, authToken) }) if err == nil { return "http/success", nil @@ -91,6 +102,27 @@ func (sender *HTTPNotificationSender) Send( return "", sender.notificationError(hostname, "notification request failed") } +// getAuthToken generates a JWT bearer token for the HTTPS adapter, signed with the phone ID +// the same way webhook requests are signed with the webhook signing key. The phone ID is only +// used as the HMAC secret and is intentionally not embedded in any claim: the adapter already +// knows which phone ID to verify against from its own gateway registration, and putting the +// phone ID in a readable claim would let anyone who intercepts one token read the signing +// secret and forge further tokens. +func (sender *HTTPNotificationSender) getAuthToken(endpoint *url.URL, phoneID uuid.UUID) (string, error) { + audience := *endpoint + audience.User = nil + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{audience.String()}, + ExpiresAt: jwt.NewNumericDate(now.Add(notificationJWTValidity)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-notificationJWTValidity)), + }) + return token.SignedString([]byte(phoneID.String())) +} + func encodeHTTPNotificationPayload(message *messaging.Message) ([]byte, error) { return json.Marshal(map[string]any{ "message": message, @@ -101,6 +133,7 @@ func (sender *HTTPNotificationSender) deliver( ctx context.Context, endpoint *url.URL, body []byte, + authToken string, ) error { if err := ctx.Err(); err != nil { return terminalNotificationRequestError{cause: err} @@ -109,7 +142,7 @@ func (sender *HTTPNotificationSender) deliver( attemptCtx, cancel := context.WithTimeout(ctx, sender.timeout) defer cancel() - request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body) + request, err := createHTTPNotificationRequest(attemptCtx, endpoint, body, authToken) if err != nil { return terminalNotificationRequestError{cause: err} } @@ -125,6 +158,7 @@ func createHTTPNotificationRequest( ctx context.Context, endpoint *url.URL, body []byte, + authToken string, ) (*http.Request, error) { request, err := http.NewRequestWithContext( ctx, @@ -136,6 +170,7 @@ func createHTTPNotificationRequest( return nil, err } request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", authToken)) return request, nil } diff --git a/api/pkg/services/http_notification_sender_test.go b/api/pkg/services/http_notification_sender_test.go index 075f99c87..018765cf0 100644 --- a/api/pkg/services/http_notification_sender_test.go +++ b/api/pkg/services/http_notification_sender_test.go @@ -8,16 +8,21 @@ import ( "net/http" "net/url" "reflect" + "strings" "testing" "time" "firebase.google.com/go/messaging" "github.com/NdoleStudio/httpsms/pkg/telemetry" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" ) +var testNotificationPhoneID = uuid.New() + type roundTripFunc func(*http.Request) (*http.Response, error) func (roundTrip roundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { @@ -57,10 +62,20 @@ func TestHTTPNotificationSenderSendsFCMCompatiblePayload(t *testing.T) { assert.Equal(t, "high", payload.Message.Android.Priority) assert.Equal(t, "600s", payload.Message.Android.TTL) + token, err := jwt.Parse(strings.TrimPrefix(request.Header.Get("Authorization"), "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + assert.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + assert.Equal(t, "api.httpsms.com", claims["iss"]) + return response(http.StatusNoContent, http.NoBody), nil })) - result, err := sender.Send(context.Background(), message) + result, err := sender.Send(context.Background(), message, testNotificationPhoneID) require.NoError(t, err) assert.Equal(t, "http/success", result) @@ -150,6 +165,7 @@ func TestHTTPNotificationSenderRetriesOnlyTransientFailures(t *testing.T) { result, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) if test.wantErr { @@ -179,6 +195,7 @@ func TestHTTPNotificationSenderReusesRetrierAcrossSends(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.NoError(t, err) } @@ -206,6 +223,7 @@ func TestHTTPNotificationSenderCreatesFreshRequestAndBodyForEveryAttempt(t *test Token: "https://adapter.example.com/notify", Data: map[string]string{"KEY_MESSAGE_ID": "message-1"}, }, + testNotificationPhoneID, ) require.NoError(t, err) @@ -227,6 +245,7 @@ func TestHTTPNotificationSenderBoundsResponseBodyDiscard(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.NoError(t, err) @@ -253,6 +272,7 @@ func TestHTTPNotificationSenderOmitsTTLForHeartbeat(t *testing.T) { Priority: "high", }, }, + testNotificationPhoneID, ) require.NoError(t, err) @@ -274,12 +294,29 @@ func TestHTTPNotificationSenderUsesInjectedHTTPClientUnchanged(t *testing.T) { assert.Equal(t, time.Minute, sender.client.Timeout) } -func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { +func TestHTTPNotificationSenderIgnoresEndpointUserInformation(t *testing.T) { + // Adapter endpoints must not rely on HTTP basic auth embedded in the URL; the Authorization + // header always carries the phone-signed JWT instead. sender := newHTTPNotificationSender(t, roundTripFunc(func(request *http.Request) (*http.Response, error) { - username, password, ok := request.BasicAuth() - assert.True(t, ok) - assert.Equal(t, "adapter-user", username) - assert.Equal(t, "adapter-password", password) + _, _, ok := request.BasicAuth() + assert.False(t, ok) + + authorization := request.Header.Get("Authorization") + assert.True(t, strings.HasPrefix(authorization, "Bearer ")) + + token, err := jwt.Parse(strings.TrimPrefix(authorization, "Bearer "), func(*jwt.Token) (interface{}, error) { + return []byte(testNotificationPhoneID.String()), nil + }) + require.NoError(t, err) + assert.True(t, token.Valid) + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok) + audience, err := claims.GetAudience() + require.NoError(t, err) + require.Len(t, audience, 1) + assert.NotContains(t, audience[0], "adapter-user") + assert.NotContains(t, audience[0], "adapter-password") + return response(http.StatusNoContent, http.NoBody), nil })) endpoint := &url.URL{ @@ -292,6 +329,7 @@ func TestHTTPNotificationSenderAllowsEndpointUserInformation(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: endpoint.String()}, + testNotificationPhoneID, ) require.NoError(t, err) @@ -309,6 +347,7 @@ func TestHTTPNotificationSenderBoundsEveryAttemptByTimeout(t *testing.T) { _, err := sender.Send( context.Background(), &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.Error(t, err) @@ -328,6 +367,7 @@ func TestHTTPNotificationSenderStopsRetriesWhenParentContextIsCancelled(t *testi _, err := sender.Send( ctx, &messaging.Message{Token: "https://adapter.example.com/notify"}, + testNotificationPhoneID, ) require.Error(t, err) @@ -339,7 +379,7 @@ func TestHTTPNotificationSenderRejectsNilMessage(t *testing.T) { return response(http.StatusNoContent, http.NoBody), nil })) - _, err := sender.Send(context.Background(), nil) + _, err := sender.Send(context.Background(), nil, testNotificationPhoneID) require.Error(t, err) assert.Contains(t, err.Error(), "notification message is nil") diff --git a/api/pkg/services/phone_notification_service.go b/api/pkg/services/phone_notification_service.go index d3166854a..2c63fcd7c 100644 --- a/api/pkg/services/phone_notification_service.go +++ b/api/pkg/services/phone_notification_service.go @@ -215,7 +215,7 @@ func (service *PhoneNotificationService) sendPhoneNotification( } message.Token = strings.TrimSpace(*phone.FcmToken) - result, err := client.Send(ctx, message) + result, err := client.Send(ctx, message, phone.ID) if err != nil { return "", transport, stacktrace.Propagatef( err, diff --git a/api/pkg/services/phone_notification_service_test.go b/api/pkg/services/phone_notification_service_test.go index 9908f5d97..9f0c42a46 100644 --- a/api/pkg/services/phone_notification_service_test.go +++ b/api/pkg/services/phone_notification_service_test.go @@ -90,6 +90,7 @@ func (logger *phoneNotificationLogger) Printf(string, ...interface{}) {} type recordingPhoneNotificationClient struct { message *messaging.Message + phoneID uuid.UUID result string err error calls int @@ -98,9 +99,11 @@ type recordingPhoneNotificationClient struct { func (client *recordingPhoneNotificationClient) Send( _ context.Context, message *messaging.Message, + phoneID uuid.UUID, ) (string, error) { client.calls++ client.message = message + client.phoneID = phoneID return client.result, client.err } @@ -122,6 +125,7 @@ func TestPhoneNotificationServiceSendPhoneNotificationUsesMappedClient(t *testin assert.Equal(t, entities.NotificationTransportHTTP, transport) assert.Equal(t, "https://adapter.example.com/notify", message.Token) assert.Same(t, message, httpClient.message) + assert.Equal(t, phone.ID, httpClient.phoneID) assert.Equal(t, 1, httpClient.calls) } diff --git a/tests/adapter-emulator/control_handler.go b/tests/adapter-emulator/control_handler.go index cfe066891..7211a034a 100644 --- a/tests/adapter-emulator/control_handler.go +++ b/tests/adapter-emulator/control_handler.go @@ -14,6 +14,7 @@ const maxControlBodyBytes = 1024 * 1024 type gatewayRegistration struct { PhoneNumber string `json:"phone_number"` PhoneAPIKey string `json:"phone_api_key"` + PhoneID string `json:"phone_id"` } type incomingMessageRequest struct { @@ -39,8 +40,9 @@ func (instance *emulator) handleGatewayRegistration(writer http.ResponseWriter, } registration.PhoneNumber = strings.TrimSpace(registration.PhoneNumber) registration.PhoneAPIKey = strings.TrimSpace(registration.PhoneAPIKey) - if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" { - writeControlError(writer, http.StatusBadRequest, errors.New("phone_number and phone_api_key are required")) + registration.PhoneID = strings.TrimSpace(registration.PhoneID) + if registration.PhoneNumber == "" || registration.PhoneAPIKey == "" || registration.PhoneID == "" { + writeControlError(writer, http.StatusBadRequest, errors.New("phone_number, phone_api_key and phone_id are required")) return } diff --git a/tests/adapter-emulator/emulator.go b/tests/adapter-emulator/emulator.go index 13d0568d7..bcc2fe70a 100644 --- a/tests/adapter-emulator/emulator.go +++ b/tests/adapter-emulator/emulator.go @@ -9,15 +9,17 @@ import ( type gateway struct { PhoneNumber string PhoneAPIKey string + PhoneID string } type notificationRecord struct { - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` + Authorization string `json:"authorization,omitempty"` } type emulator struct { @@ -43,6 +45,7 @@ func (instance *emulator) registerGateway(gatewayID string, registration gateway instance.gateways[gatewayID] = gateway{ PhoneNumber: registration.PhoneNumber, PhoneAPIKey: registration.PhoneAPIKey, + PhoneID: registration.PhoneID, } } @@ -59,15 +62,17 @@ func (instance *emulator) recordNotification( data map[string]string, kind string, messageID string, + authorization string, ) *notificationRecord { instance.mu.Lock() defer instance.mu.Unlock() record := ¬ificationRecord{ - GatewayID: gatewayID, - Data: copyStringMap(data), - MessageID: messageID, - Kind: kind, + GatewayID: gatewayID, + Data: copyStringMap(data), + MessageID: messageID, + Kind: kind, + Authorization: authorization, } instance.records = append(instance.records, record) diff --git a/tests/adapter-emulator/emulator_test.go b/tests/adapter-emulator/emulator_test.go index b8be3c2b0..28d5b1dd0 100644 --- a/tests/adapter-emulator/emulator_test.go +++ b/tests/adapter-emulator/emulator_test.go @@ -9,8 +9,13 @@ import ( "strings" "sync" "testing" + "time" + + "github.com/golang-jwt/jwt/v5" ) +const testGatewayPhoneID = "11111111-1111-1111-1111-111111111111" + func TestRecordNotificationCopiesRecords(t *testing.T) { t.Parallel() @@ -18,6 +23,7 @@ func TestRecordNotificationCopiesRecords(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) record := instance.recordNotification( @@ -25,6 +31,7 @@ func TestRecordNotificationCopiesRecords(t *testing.T) { map[string]string{"KEY_MESSAGE_ID": "message-1"}, "message", "message-1", + "Bearer test-token", ) instance.markNotificationProcessed(record) @@ -90,6 +97,7 @@ func TestNotificationHandlerProcessesMessage(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) @@ -98,6 +106,7 @@ func TestNotificationHandlerProcessesMessage(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(body), ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusNoContent { @@ -146,6 +155,7 @@ func TestNotificationHandlerStoresHeartbeat(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) request := httptest.NewRequest( @@ -153,6 +163,7 @@ func TestNotificationHandlerStoresHeartbeat(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(callbackBody(t, map[string]string{"KEY_HEARTBEAT_ID": "heartbeat-1"})), ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusNoContent { @@ -184,6 +195,7 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) request := httptest.NewRequest( @@ -191,6 +203,7 @@ func TestNotificationHandlerRetainsProcessingFailure(t *testing.T) { "/notifications/gateway-1", bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), ) + request.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) response := httptest.NewRecorder() instance.notificationHandler().ServeHTTP(response, request) if response.Code != http.StatusInternalServerError { @@ -250,17 +263,20 @@ func TestNotificationHandlerProcessesRetryAfterFailure(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) handler := instance.notificationHandler() body := callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"}) firstRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + firstRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) firstResponse := httptest.NewRecorder() handler.ServeHTTP(firstResponse, firstRequest) if firstResponse.Code != http.StatusInternalServerError { t.Fatalf("first callback status = %d, want 500: %s", firstResponse.Code, firstResponse.Body.String()) } secondRequest := httptest.NewRequest(http.MethodPost, "/notifications/gateway-1", bytes.NewReader(body)) + secondRequest.Header.Set("Authorization", validNotificationToken(t, testGatewayPhoneID)) secondResponse := httptest.NewRecorder() handler.ServeHTTP(secondResponse, secondRequest) if secondResponse.Code != http.StatusNoContent { @@ -315,6 +331,7 @@ func TestControlHandlerRegistersGatewayAndReceivesIncomingMessage(t *testing.T) registration := performJSONRequest(t, handler, http.MethodPut, "/test/gateways/gateway-1", map[string]any{ "phone_number": "+18005550199", "phone_api_key": "phone-key", + "phone_id": testGatewayPhoneID, }) if registration.Code != http.StatusNoContent { t.Fatalf("registration status = %d, want 204: %s", registration.Code, registration.Body.String()) @@ -368,18 +385,21 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { instance.registerGateway("gateway-1", gatewayRegistration{ PhoneNumber: "+18005550199", PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, }) instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-1"}, "message", "message-1", + "Bearer test-token", ) instance.recordNotification( "gateway-1", map[string]string{"KEY_MESSAGE_ID": "message-2"}, "message", "message-2", + "Bearer test-token", ) response := httptest.NewRecorder() @@ -406,6 +426,114 @@ func TestControlHandlerFiltersNotificationRecordsByMessageID(t *testing.T) { } } +func TestNotificationHandlerRejectsMissingAuthorization(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for an unauthenticated request", len(records)) + } +} + +func TestNotificationHandlerRejectsTokenSignedWithWrongSecret(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", validNotificationToken(t, "some-other-phone-id")) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for a request signed with the wrong secret", len(records)) + } +} + +func TestNotificationHandlerRejectsNonHS256SigningMethod(t *testing.T) { + t.Parallel() + + instance := newEmulator("http://api.example", http.DefaultClient) + instance.registerGateway("gateway-1", gatewayRegistration{ + PhoneNumber: "+18005550199", + PhoneAPIKey: "phone-key", + PhoneID: testGatewayPhoneID, + }) + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS384, jwt.RegisteredClaims{ + Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + }) + signed, err := token.SignedString([]byte(testGatewayPhoneID)) + if err != nil { + t.Fatalf("sign notification token: %v", err) + } + + request := httptest.NewRequest( + http.MethodPost, + "/notifications/gateway-1", + bytes.NewReader(callbackBody(t, map[string]string{"KEY_MESSAGE_ID": "message-1"})), + ) + request.Header.Set("Authorization", "Bearer "+signed) + response := httptest.NewRecorder() + instance.notificationHandler().ServeHTTP(response, request) + if response.Code != http.StatusUnauthorized { + t.Fatalf("callback status = %d, want 401: %s", response.Code, response.Body.String()) + } + if records := instance.listGatewayRecords("gateway-1"); len(records) != 0 { + t.Fatalf("record count = %d, want 0 for a request signed with a non-HS256 method", len(records)) + } +} + +func validNotificationToken(t *testing.T, phoneID string) string { + t.Helper() + + now := time.Now().UTC() + token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.RegisteredClaims{ + Audience: []string{"https://adapter-emulator:9091/notifications/gateway-1"}, + ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)), + IssuedAt: jwt.NewNumericDate(now), + Issuer: notificationJWTIssuer, + NotBefore: jwt.NewNumericDate(now.Add(-10 * time.Minute)), + }) + signed, err := token.SignedString([]byte(phoneID)) + if err != nil { + t.Fatalf("sign notification token: %v", err) + } + return "Bearer " + signed +} + func callbackBody(t *testing.T, data map[string]string) []byte { t.Helper() diff --git a/tests/adapter-emulator/go.mod b/tests/adapter-emulator/go.mod index 399833be5..31faad6df 100644 --- a/tests/adapter-emulator/go.mod +++ b/tests/adapter-emulator/go.mod @@ -1,3 +1,5 @@ module github.com/NdoleStudio/httpsms/tests/adapter-emulator go 1.25.0 + +require github.com/golang-jwt/jwt/v5 v5.3.1 diff --git a/tests/adapter-emulator/go.sum b/tests/adapter-emulator/go.sum new file mode 100644 index 000000000..c0f729031 --- /dev/null +++ b/tests/adapter-emulator/go.sum @@ -0,0 +1,2 @@ +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= diff --git a/tests/adapter-emulator/notification_handler.go b/tests/adapter-emulator/notification_handler.go index e4108eea7..e17415f4f 100644 --- a/tests/adapter-emulator/notification_handler.go +++ b/tests/adapter-emulator/notification_handler.go @@ -6,10 +6,16 @@ import ( "log" "net/http" "strings" + + "github.com/golang-jwt/jwt/v5" ) const maxCallbackBodyBytes = 1024 * 1024 +// notificationJWTIssuer must match the issuer the httpSMS API signs adapter notification +// tokens with (see api/pkg/services/http_notification_sender.go). +const notificationJWTIssuer = "api.httpsms.com" + type callbackEnvelope struct { Message struct { Token string `json:"token"` @@ -31,6 +37,13 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request return } + authorization := request.Header.Get("Authorization") + if err := verifyNotificationAuth(authorization, registeredGateway.PhoneID); err != nil { + log.Printf("[ADAPTER] rejected notification for gateway=%s: %v", gatewayID, err) + http.Error(writer, fmt.Sprintf("invalid notification token: %v", err), http.StatusUnauthorized) + return + } + request.Body = http.MaxBytesReader(writer, request.Body, maxCallbackBodyBytes) var envelope callbackEnvelope if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil { @@ -44,6 +57,7 @@ func (instance *emulator) handleNotification(writer http.ResponseWriter, request envelope.Message.Data, kind, messageID, + authorization, ) log.Printf( "[ADAPTER] callback gateway=%s data=%v", @@ -94,3 +108,33 @@ func notificationKind(data map[string]string) (kind string, messageID string, er return "", "", fmt.Errorf("unsupported notification data") } } + +// verifyNotificationAuth validates the JWT the httpSMS API signs notification requests with, +// using the gateway's phone ID as the HMAC-SHA256 secret (see +// api/pkg/services/http_notification_sender.go getAuthToken). The phone ID is never carried in +// a token claim, only used as the secret, so verification relies on the gateway's own +// registration to know which phone ID to check against rather than trusting a claim. +func verifyNotificationAuth(authorization string, phoneID string) error { + tokenString, ok := strings.CutPrefix(authorization, "Bearer ") + if !ok || strings.TrimSpace(tokenString) == "" { + return fmt.Errorf("missing bearer token") + } + + claims := jwt.RegisteredClaims{} + token, err := jwt.ParseWithClaims(tokenString, &claims, func(token *jwt.Token) (interface{}, error) { + if token.Method != jwt.SigningMethodHS256 { + return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) + } + return []byte(phoneID), nil + }) + if err != nil { + return fmt.Errorf("parse token: %w", err) + } + if !token.Valid { + return fmt.Errorf("token is not valid") + } + if claims.Issuer != notificationJWTIssuer { + return fmt.Errorf("issuer mismatch") + } + return nil +} diff --git a/tests/adapter_integration_test.go b/tests/adapter_integration_test.go index 6dea41f1b..70dbbe894 100644 --- a/tests/adapter_integration_test.go +++ b/tests/adapter_integration_test.go @@ -37,6 +37,7 @@ func TestAdapterGatewayOutgoingMessage(t *testing.T) { assert.Equal(t, "message", records[0].Kind) assert.True(t, records[0].Processed) assert.Equal(t, messageID, records[0].Data["KEY_MESSAGE_ID"]) + assertAdapterNotificationJWT(t, records[0], phone.PhoneID) } func TestAdapterGatewayIncomingMessage(t *testing.T) { @@ -79,6 +80,7 @@ func TestAdapterGatewayHeartbeatWakeUp(t *testing.T) { record := waitForAdapterHeartbeatRecord(t, phone.GatewayID, 30*time.Second) assert.Equal(t, "heartbeat", record.Kind) assert.NotEmpty(t, record.Data["KEY_HEARTBEAT_ID"]) + assertAdapterNotificationJWT(t, record, phone.PhoneID) heartbeats, response, err := newAPIClient().Heartbeats.Index(ctx, &httpsms.HeartbeatIndexParams{ Owner: phone.PhoneNumber, diff --git a/tests/helpers_test.go b/tests/helpers_test.go index a0c84c412..797b2f09c 100644 --- a/tests/helpers_test.go +++ b/tests/helpers_test.go @@ -45,12 +45,13 @@ type adapterTestPhone struct { } type notificationRecord struct { - GatewayID string `json:"gateway_id"` - Data map[string]string `json:"data"` - MessageID string `json:"message_id,omitempty"` - Kind string `json:"kind"` - Processed bool `json:"processed"` - Error string `json:"error,omitempty"` + GatewayID string `json:"gateway_id"` + Data map[string]string `json:"data"` + MessageID string `json:"message_id,omitempty"` + Kind string `json:"kind"` + Processed bool `json:"processed"` + Error string `json:"error,omitempty"` + Authorization string `json:"authorization,omitempty"` } func newAPIClient() *httpsms.Client { @@ -153,9 +154,26 @@ func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint phoneAPIKey := apiKeyResponse.Data.APIKey require.NotEmpty(t, phoneAPIKey) + // Upsert the phone first so its ID is known before the adapter emulator is registered: + // the API signs notification requests with a JWT keyed by the phone ID, and the emulator + // needs that ID up front to validate the JWT on every notification it receives. + callbackURL := fmt.Sprintf("https://adapter-emulator:9091/notifications/%s", gatewayID) + phoneResponse, response, err := client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ + PhoneNumber: phoneNumber, + FcmToken: callbackURL, + MessagesPerMinute: messagesPerMinute, + MaxSendAttempts: 2, + MessageExpirationSeconds: 600, + SIM: "SIM1", + }) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone upsert failed") + require.NotEmpty(t, phoneResponse.Data.ID) + registrationBody, err := json.Marshal(map[string]any{ "phone_number": phoneNumber, "phone_api_key": phoneAPIKey, + "phone_id": phoneResponse.Data.ID, }) require.NoError(t, err) registrationRequest, err := http.NewRequestWithContext( @@ -179,19 +197,6 @@ func setupAdapterPhone(ctx context.Context, t *testing.T, messagesPerMinute uint string(registrationResponseBody), ) - callbackURL := fmt.Sprintf("https://adapter-emulator:9091/notifications/%s", gatewayID) - phoneResponse, response, err := client.Phones.Upsert(ctx, &httpsms.PhoneUpsertParams{ - PhoneNumber: phoneNumber, - FcmToken: callbackURL, - MessagesPerMinute: messagesPerMinute, - MaxSendAttempts: 2, - MessageExpirationSeconds: 600, - SIM: "SIM1", - }) - require.NoError(t, err) - require.Equal(t, http.StatusOK, response.HTTPResponse.StatusCode, "phone upsert failed") - require.NotEmpty(t, phoneResponse.Data.ID) - phoneClient := newPhoneClient(phoneAPIKey) _, response, err = phoneClient.Phones.UpsertFCMToken(ctx, &httpsms.PhoneFCMTokenParams{ PhoneNumber: phoneNumber, @@ -580,6 +585,39 @@ func assertWebhookJWT(t *testing.T, request wmJournal.Request, signingKey string require.True(t, nbf.Before(time.Now()), "token not yet valid") } +// assertAdapterNotificationJWT validates the JWT the API signs adapter notification requests +// with, using the receiving phone's ID as the HMAC-SHA256 secret (see +// api/pkg/services/http_notification_sender.go getAuthToken). The phone ID is only used as the +// secret and is never embedded in a claim, so this only checks the signature and issuer, not a +// subject; the adapter emulator itself rejects notifications with an invalid signature (401). +func assertAdapterNotificationJWT(t *testing.T, record notificationRecord, phoneID string) { + t.Helper() + + require.NotEmpty(t, record.Authorization, "adapter notification record missing Authorization header") + require.True(t, strings.HasPrefix(record.Authorization, "Bearer "), "Authorization header must start with Bearer") + + tokenString := strings.TrimPrefix(record.Authorization, "Bearer ") + token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) { + require.Equal(t, jwt.SigningMethodHS256, token.Method, "unexpected signing method") + return []byte(phoneID), nil + }) + require.NoError(t, err, "JWT validation failed") + require.True(t, token.Valid, "JWT token is not valid") + + claims, ok := token.Claims.(jwt.MapClaims) + require.True(t, ok, "cannot parse claims") + require.Equal(t, "api.httpsms.com", claims["iss"], "issuer mismatch") + require.Empty(t, claims["sub"], "phone ID must not be embedded in a claim since it is also the signing secret") + + exp, err := claims.GetExpirationTime() + require.NoError(t, err) + require.True(t, exp.After(time.Now()), "token is expired") + + nbf, err := claims.GetNotBefore() + require.NoError(t, err) + require.True(t, nbf.Before(time.Now()), "token not yet valid") +} + func waitForWebhookEvents(t *testing.T, webhookPath string, expectedCount int, timeout time.Duration) []wmJournal.GetRequestResponse { t.Helper() deadline := time.Now().Add(timeout) diff --git a/tests/integration_test.go b/tests/integration_test.go index 7cc16809a..8b7436642 100644 --- a/tests/integration_test.go +++ b/tests/integration_test.go @@ -200,14 +200,14 @@ func TestSendSMS_RateLimit(t *testing.T) { assert.GreaterOrEqual(t, gapMs, int64(5500), "rate limit gap should be >= 5500ms (6s minus timing tolerance), got %dms", gapMs) fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") - pollMessageStatus(ctx, t, msgID1, "sent", 15*time.Second) + pollMessageStatus(ctx, t, msgID1, "sent", 30*time.Second) fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "SENT") - pollMessageStatus(ctx, t, msgID2, "sent", 15*time.Second) + pollMessageStatus(ctx, t, msgID2, "sent", 30*time.Second) fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "DELIVERED") fireEvent(ctx, t, phone.PhoneAPIKey, msgID2, "DELIVERED") - msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 15*time.Second) - msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 15*time.Second) + msg1 := pollMessageStatus(ctx, t, msgID1, "delivered", 30*time.Second) + msg2 := pollMessageStatus(ctx, t, msgID2, "delivered", 30*time.Second) assert.Equal(t, "delivered", msg1.Status) assert.Equal(t, "delivered", msg2.Status) @@ -449,6 +449,7 @@ func TestBulkSMS_CSV(t *testing.T) { // Wait for FCM push waitForFCMPush(t, messageID, 30*time.Second) + pollMessageStatus(ctx, t, messageID, "scheduled", 15*time.Second) // Fire SENT event fireEvent(ctx, t, phone.PhoneAPIKey, messageID, "SENT") @@ -537,6 +538,7 @@ func TestBulkSMS_Excel(t *testing.T) { msgID2 := bulkMessages[1].ID.String() waitForFCMPush(t, msgID1, 30*time.Second) waitForFCMPush(t, msgID2, 30*time.Second) + pollMessageStatus(ctx, t, msgID1, "scheduled", 15*time.Second) // Fire SENT then DELIVERED on message 1, leave message 2 pending fireEvent(ctx, t, phone.PhoneAPIKey, msgID1, "SENT") diff --git a/web/app/components/MessageThread.vue b/web/app/components/MessageThread.vue index 93c77324d..2a699408a 100644 --- a/web/app/components/MessageThread.vue +++ b/web/app/components/MessageThread.vue @@ -8,6 +8,10 @@ import { mdiAccount, } from '@mdi/js' import type { EntitiesMessageThread } from '~~/shared/types/api' +import { + isUrlNotificationToken, + formatWhatsappText, +} from '~/utils/whatsapp-format' const threadsStore = useThreadsStore() const phonesStore = usePhonesStore() @@ -45,6 +49,19 @@ function onInstallApp() { }) } +function isWhatsappFormatted(thread: EntitiesMessageThread): boolean { + const phone = phonesStore.phones.find((x) => x.phone_number === thread.owner) + return isUrlNotificationToken(phone?.fcm_token) +} + +function threadPreviewHtml(thread: EntitiesMessageThread): string { + // Links are unwrapped because the preview sits inside a router link. + return formatWhatsappText(thread.last_message_content ?? '').replace( + /]*>(.*?)<\/a>/g, + '$1', + ) +} + function threadContactName(thread: EntitiesMessageThread): string { return thread.contact_details?.name?.trim() ?? '' } @@ -160,7 +177,13 @@ function threadAvatarInitial(thread: EntitiesMessageThread): string { }" style="max-width: 250px" > - {{ thread.last_message_content }} + + + +