From 4615a5c48eed2ff32ee67317f760a79ed47a911b Mon Sep 17 00:00:00 2001 From: Josephine Pfeiffer Date: Sat, 6 Sep 2025 22:16:54 +0200 Subject: [PATCH] Add server-initiated heartbeat checks via PWA push notifications - Add VAPID key configuration and environment setup - Implement push subscription storage and management in Redis - Add background monitoring for inactive devices - Create push notification API endpoints (/api/push-*) - Add statistics endpoint for monitoring push notification system --- PUSH_NOTIFICATIONS.md | 52 +++++ api.go | 36 +++- go.mod | 10 +- go.sum | 175 ++++------------- main.go | 7 + push.go | 440 ++++++++++++++++++++++++++++++++++++++++++ push_db.go | 361 ++++++++++++++++++++++++++++++++++ 7 files changed, 934 insertions(+), 147 deletions(-) create mode 100644 PUSH_NOTIFICATIONS.md create mode 100644 push.go create mode 100644 push_db.go diff --git a/PUSH_NOTIFICATIONS.md b/PUSH_NOTIFICATIONS.md new file mode 100644 index 0000000..9fe4d68 --- /dev/null +++ b/PUSH_NOTIFICATIONS.md @@ -0,0 +1,52 @@ +# Push Notifications for Heartbeat + +## Overview + +Server-initiated heartbeat checks allow the heartbeat server to actively monitor device status by sending push notifications that wake up PWA service workers and trigger immediate heartbeat responses. This works even when the PWA is completely closed. + +## Setup Guide + +### 1. Generate VAPID Keys + +VAPID (Voluntary Application Server Identification) keys are required for web push notifications: + +```bash +# Install web-push CLI globally +npm install -g web-push + +# Generate VAPID key pair +web-push generate-vapid-keys + +# Output example: +# Public Key: BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U +# Private Key: tUkzMcPbtl2-xZ4Z5A1OqiELPo2Pc9-SFNw6hU8_Hh0 +``` + +- The private key should only be on your heartbeat server +- The public key needs to be in both server config AND PWA client code + +### 2. Configure Heartbeat Server + +Add the following to your `config/.env` file: + +```bash +# VAPID Configuration for Push Notifications +VAPID_PUBLIC_KEY=BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U +VAPID_PRIVATE_KEY=tUkzMcPbtl2-xZ4Z5A1OqiELPo2Pc9-SFNw6hU8_Hh0 +VAPID_SUBJECT=mailto:your-email@example.com + +# Push Monitoring (Optional) +PUSH_CHECK_INTERVAL=5m # How often to check for inactive devices +PUSH_INACTIVITY_THRESHOLD=1h # Trigger push if no heartbeat for this long +``` + +### 3. Configure PWA Client + +The PWA now supports configuring the VAPID public key directly through the user interface: + +1. **Open the PWA** in your browser or installed app +2. **Enter the VAPID Public Key** in the configuration section +3. **Save Configuration** to persist your settings +4. **Enable Server Checks** to subscribe to push notifications + +The VAPID public key field accepts your generated public key (e.g., `BEl62iUYgUivxIkv69yViEuiBIa-Ib9-SkvMeAtA3LFgDzkrxZJjSgSnfckjBJuBkr3qBUYIHBQFLXYp5Nksh8U`) diff --git a/api.go b/api.go index a7fca8a..ec599a5 100644 --- a/api.go +++ b/api.go @@ -18,12 +18,26 @@ var ( apiInfoPath = "/api/info" apiStatsPath = "/api/stats" apiDevicesPath = "/api/devices" + apiPushSubscribePath = "/api/push-subscribe" + apiPushUnsubscribePath = "/api/push-unsubscribe" + apiPushTestPath = "/api/push-test" jsonMime = "application/json" ) func ApiHandler(ctx *fasthttp.RequestCtx, path string) { + // Add CORS headers to all responses + ctx.Response.Header.Set("Access-Control-Allow-Origin", "*") + ctx.Response.Header.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + ctx.Response.Header.Set("Access-Control-Allow-Headers", "Content-Type, Auth, Device") + + // Handle CORS preflight requests first, before any other checks + if ctx.IsOptions() { + ctx.SetStatusCode(200) + return + } + switch path { - case apiBeatPath, apiUpdateStatsPath, apiUpdateDevicesPath: + case apiBeatPath, apiUpdateStatsPath, apiUpdateDevicesPath, apiPushSubscribePath, apiPushUnsubscribePath: if !ctx.IsPost() { ErrorBadRequest(ctx, true) return @@ -32,6 +46,20 @@ func ApiHandler(ctx *fasthttp.RequestCtx, path string) { // The authentication key provided with said Auth header header := ctx.Request.Header.Peek("Auth") + // Make sure Auth key is correct + if string(header) != authToken { + ErrorForbidden(ctx, true) + return + } + case apiPushTestPath: + if !ctx.IsGet() { + ErrorBadRequest(ctx, true) + return + } + + // The authentication key provided with said Auth header + header := ctx.Request.Header.Peek("Auth") + // Make sure Auth key is correct if string(header) != authToken { ErrorForbidden(ctx, true) @@ -55,6 +83,12 @@ func ApiHandler(ctx *fasthttp.RequestCtx, path string) { handleUpdateStats(ctx) case apiUpdateDevicesPath: handleUpdateDevices(ctx) + case apiPushSubscribePath: + HandlePushSubscribe(ctx) + case apiPushUnsubscribePath: + HandlePushUnsubscribe(ctx) + case apiPushTestPath: + HandlePushTest(ctx) case apiInfoPath: handleJsonObject(ctx, FormattedInfo()) case apiStatsPath: diff --git a/go.mod b/go.mod index 3791751..002c61c 100644 --- a/go.mod +++ b/go.mod @@ -3,23 +3,23 @@ module github.com/5HT2B/heartbeat go 1.20 require ( + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/ferluci/fast-realip v1.0.1 - github.com/go-redis/redis/v8 v8.11.5 github.com/joho/godotenv v1.5.1 github.com/nitishm/go-rejson/v4 v4.2.0 + github.com/redis/go-redis/v9 v9.6.0 github.com/valyala/fasthttp v1.55.0 github.com/valyala/quicktemplate v1.8.0 - golang.org/x/text v0.16.0 + golang.org/x/text v0.21.0 ) require ( github.com/andybalholm/brotli v1.1.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect github.com/gomodule/redigo v1.8.8 // indirect github.com/klauspost/compress v1.17.9 // indirect - github.com/redis/go-redis/v9 v9.6.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - golang.org/x/net v0.26.0 // indirect - golang.org/x/sys v0.21.0 // indirect + golang.org/x/crypto v0.31.0 // indirect ) diff --git a/go.sum b/go.sum index 62ca633..96bc68d 100644 --- a/go.sum +++ b/go.sum @@ -1,98 +1,42 @@ -github.com/andybalholm/brotli v1.0.2/go.mod h1:loMXtMfwqflxFJPmdbJO0a3KNoPuLBgiu3qAvBg8x/Y= -github.com/andybalholm/brotli v1.0.3/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= -github.com/andybalholm/brotli v1.0.5 h1:8uQZIdzKmjc/iuPu7O2ioW48L81FgatrcpfFmiq/cCs= -github.com/andybalholm/brotli v1.0.5/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/bsm/ginkgo/v2 v2.5.0/go.mod h1:AiKlXPm7ItEHNc/2+OkrNG4E0ITzojb9/xWzvQ9XZ9w= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= github.com/bsm/gomega v1.20.0/go.mod h1:JifAceMQ4crZIWYUKrlGcmbN3bqHogVTADMD2ATsbwk= -github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.1.2/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= -github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= -github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/ferluci/fast-realip v1.0.1 h1:zPi0iv7zgOOlM/qJt9mozLz5IjVRAezaqHJoQ0JJ/yI= github.com/ferluci/fast-realip v1.0.1/go.mod h1:Ag7xdRQ9GOCL/pwbDe4zJv6SlfYdROArc8O+qIKhRc4= -github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= -github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= -github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= -github.com/go-redis/redis/v8 v8.4.4/go.mod h1:nA0bQuF0i5JFx4Ta9RZxGKXFrQ8cRWntra97f0196iY= -github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= -github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= -github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= -github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= -github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= -github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= -github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= -github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= -github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= -github.com/golang/snappy v0.0.3/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/gomodule/redigo v1.8.3/go.mod h1:P9dn9mFrCBvWhGE1wpxx6fgq7BAeLBk+UUUzlpkBYO0= github.com/gomodule/redigo v1.8.8 h1:f6cXq6RRfiyrOJEV7p3JhLDlmawGBVBBP1MggY8Mo4E= github.com/gomodule/redigo v1.8.8/go.mod h1:7ArFNvsTjH8GMMzB4uy1snslv2BwmginuMs06a1uzZE= -github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= -github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= -github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/klauspost/compress v1.8.2/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.13.4/go.mod h1:8dP1Hq4DHOhN9w426knH3Rhby4rFm6D8eO+e+Dq5Gzg= -github.com/klauspost/compress v1.13.5/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk= -github.com/klauspost/compress v1.16.3/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.16.5 h1:IFV2oUNUzZaz+XyusxpLzpzS8Pt5rh0Z16For/djlyI= -github.com/klauspost/compress v1.16.5/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= -github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg= -github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= github.com/klauspost/cpuid v1.2.1/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= -github.com/nitishm/go-rejson/v4 v4.1.0 h1:NckPgP5ct9ZsQp+aueVCXBiFZ7FBUwltBkEAjg98mJY= -github.com/nitishm/go-rejson/v4 v4.1.0/go.mod h1:LG1zga7gFp/GH+0IAbXZ7rM4MJruA8B2dXvmXwV7VZo= github.com/nitishm/go-rejson/v4 v4.2.0 h1:nUsQVq92KmRtDzz8RHbaG40VKsUZWzYfXavx+wnVP+k= github.com/nitishm/go-rejson/v4 v4.2.0/go.mod h1:m/I9wZpt53OFWhY+uaBFyrbPFKctKaJ5qQnuORQ4LuQ= -github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= -github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= -github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= -github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= -github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= -github.com/onsi/ginkgo v1.14.2/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY= -github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= -github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= -github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= -github.com/onsi/ginkgo/v2 v2.0.0/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= -github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= -github.com/onsi/gomega v1.10.4/go.mod h1:g/HbgYopi++010VEqkFgJHKC09uJiW9UkXvMUuKHUCQ= -github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= -github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= -github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/redis/go-redis/v9 v9.0.2/go.mod h1:/xDTe9EF1LM61hek62Poq2nzQSGj0xSrEtEHbBQevps= -github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= -github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= github.com/redis/go-redis/v9 v9.6.0 h1:NLck+Rab3AOTHw21CGRpvQpgTrAU4sgdCswqGtlhGRA= github.com/redis/go-redis/v9 v9.6.0/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 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/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= -github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -100,129 +44,78 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o 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.9.0/go.mod h1:FstJa9V+Pj9vQ7OJie2qMHdwemEDaDiSdBnvPM1Su9w= -github.com/valyala/fasthttp v1.30.0/go.mod h1:2rsYD01CKFrjjsvFxx75KlEUNpWNBY9JWD3K/7o2Cus= -github.com/valyala/fasthttp v1.46.0 h1:6ZRhrFg8zBXTRYY6vdzbFhqsBd7FVv123pV2m9V87U4= -github.com/valyala/fasthttp v1.46.0/go.mod h1:k2zXd82h/7UZc3VOdJ2WaUqt1uZ/XpXAfE9i+HBC3lA= -github.com/valyala/fasthttp v1.52.0 h1:wqBQpxH71XW0e2g+Og4dzQM8pk34aFYlA1Ga8db7gU0= -github.com/valyala/fasthttp v1.52.0/go.mod h1:hf5C4QnVMkNXMspnsUlfM3WitlgYflyhHYoKol/szxQ= github.com/valyala/fasthttp v1.55.0 h1:Zkefzgt6a7+bVKHnu/YaYSOPfNYNisSVBo/unVCf8k8= github.com/valyala/fasthttp v1.55.0/go.mod h1:NkY9JtkrpPKmgwV3HTaS2HWaJss9RSIsRVfcxxoHiOM= -github.com/valyala/quicktemplate v1.7.0 h1:LUPTJmlVcb46OOUY3IeD9DojFpAVbsG+5WFTcjMJzCM= -github.com/valyala/quicktemplate v1.7.0/go.mod h1:sqKJnoaOF88V07vkO+9FL8fb9uZg/VPSJnLYn+LmLk8= github.com/valyala/quicktemplate v1.8.0 h1:zU0tjbIqTRgKQzFY1L42zq0qR3eh4WoQQdIdqCysW5k= github.com/valyala/quicktemplate v1.8.0/go.mod h1:qIqW8/igXt8fdrUln5kOSb+KWMaJ4Y8QUsfd1k6L2jM= github.com/valyala/tcplisten v0.0.0-20161114210144-ceec8f93295a/go.mod h1:v3UYOV9WzVtRmSR+PDvWpU/qWl4Wa5LApYYX4ZtKbio= -github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= -github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/otel v0.15.0/go.mod h1:e4GKElweB8W2gWUqbghw0B8t5MCTccc9212eNHnOHwA= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= -golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190827160401-ba9fcec4b297/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= -golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= -golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= -golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= -golang.org/x/net v0.0.0-20210510120150-4163338589ed/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= -golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +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.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= -golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= -golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 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-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20210514084401-e8d321eab015/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.13.0 h1:Af8nKPmuFypiUBjVoU9V20FiaFXOcuZI21p0ycVYYGE= -golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws= -golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0 h1:ablQoSUd0tRdKxZewP80B+BaqeKJuVhuRxj/dkrun3k= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= -golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 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.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +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/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= -golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= -google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= -google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= -google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= -google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= -google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= -gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= -gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 2f3426d..142ac61 100644 --- a/main.go +++ b/main.go @@ -34,6 +34,13 @@ func main() { // Setup DB and load values rdb, rjh = SetupDatabase() SetupLocalValues() + + // Initialize push configuration + InitPushConfiguration() + + // Start push monitoring if configured + go StartPushMonitoring() + go SetupDatabaseSaving() log.Printf("- Running heartbeat on " + protocol + addr) diff --git a/push.go b/push.go new file mode 100644 index 0000000..33020d6 --- /dev/null +++ b/push.go @@ -0,0 +1,440 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "strings" + "time" + + "github.com/SherClockHolmes/webpush-go" +) + +// PushMessage represents the payload sent to the PWA via push notification +type PushMessage struct { + Type string `json:"type"` + DeviceName string `json:"deviceName,omitempty"` + Timestamp int64 `json:"timestamp,omitempty"` +} + +// SendPushNotification sends a push notification to a specific subscription +func SendPushNotification(subscription *webpush.Subscription, message PushMessage) error { + if vapidPrivateKey == "" || vapidPublicKey == "" || vapidSubject == "" { + return fmt.Errorf("VAPID keys not configured") + } + + messageBytes, err := json.Marshal(message) + if err != nil { + return fmt.Errorf("error marshaling push message: %v", err) + } + + options := &webpush.Options{ + Subscriber: vapidSubject, + VAPIDPublicKey: vapidPublicKey, + VAPIDPrivateKey: vapidPrivateKey, + TTL: 30, // 30 seconds + } + + resp, err := webpush.SendNotification(messageBytes, subscription, options) + if err != nil { + return fmt.Errorf("error sending push notification: %v", err) + } + defer resp.Body.Close() + + // Handle different response status codes + if resp.StatusCode == 410 { + // Subscription is no longer valid, remove it + log.Printf("- Push subscription expired, removing: %s", subscription.Endpoint) + if err := RemovePushSubscription(subscription.Endpoint); err != nil { + log.Printf("- Error removing expired subscription: %v", err) + } + return fmt.Errorf("subscription expired and removed") + } + + if resp.StatusCode >= 400 { + return fmt.Errorf("push notification failed with status: %d", resp.StatusCode) + } + + return nil +} + +// SendHeartbeatCheckToDevice sends a heartbeat check request to a specific device +func SendHeartbeatCheckToDevice(deviceName, authToken string) (int, error) { + subscriptions, err := GetPushSubscriptionsByDevice(deviceName, authToken) + if err != nil { + return 0, fmt.Errorf("error getting subscriptions for device %s: %v", deviceName, err) + } + + if len(subscriptions) == 0 { + return 0, fmt.Errorf("no push subscriptions found for device %s", deviceName) + } + + message := PushMessage{ + Type: "heartbeat-request", + DeviceName: deviceName, + Timestamp: getCurrentTimestamp(), + } + + sentCount := 0 + for _, storedSub := range subscriptions { + webpushSub := &webpush.Subscription{ + Endpoint: storedSub.Endpoint, + Keys: webpush.Keys{ + P256dh: storedSub.P256dhKey, + Auth: storedSub.AuthKey, + }, + } + + if err := SendPushNotification(webpushSub, message); err != nil { + log.Printf("- Error sending push to %s: %v", storedSub.Endpoint, err) + } else { + sentCount++ + log.Printf("- Sent heartbeat check to %s (%s)", deviceName, storedSub.Endpoint) + } + } + + return sentCount, nil +} + +// SendHeartbeatCheckToAllDevices sends heartbeat check requests to all subscribed devices +func SendHeartbeatCheckToAllDevices() (int, error) { + subscriptions, err := GetAllPushSubscriptions() + if err != nil { + return 0, fmt.Errorf("error getting all subscriptions: %v", err) + } + + if len(subscriptions) == 0 { + return 0, fmt.Errorf("no push subscriptions found") + } + + sentCount := 0 + for _, storedSub := range subscriptions { + message := PushMessage{ + Type: "heartbeat-request", + DeviceName: storedSub.DeviceName, + Timestamp: getCurrentTimestamp(), + } + + webpushSub := &webpush.Subscription{ + Endpoint: storedSub.Endpoint, + Keys: webpush.Keys{ + P256dh: storedSub.P256dhKey, + Auth: storedSub.AuthKey, + }, + } + + if err := SendPushNotification(webpushSub, message); err != nil { + log.Printf("- Error sending push to %s (%s): %v", storedSub.DeviceName, storedSub.Endpoint, err) + } else { + sentCount++ + log.Printf("- Sent heartbeat check to %s (%s)", storedSub.DeviceName, storedSub.Endpoint) + } + } + + return sentCount, nil +} + +// getCurrentTimestamp returns the current Unix timestamp +func getCurrentTimestamp() int64 { + return time.Now().Unix() +} + +// ValidateVAPIDConfiguration checks if VAPID keys are properly configured +func ValidateVAPIDConfiguration() error { + if vapidPrivateKey == "" { + return fmt.Errorf("VAPID_PRIVATE_KEY not set in environment") + } + if vapidPublicKey == "" { + return fmt.Errorf("VAPID_PUBLIC_KEY not set in environment") + } + if vapidSubject == "" { + return fmt.Errorf("VAPID_SUBJECT not set in environment") + } + return nil +} + +// LogVAPIDConfiguration logs the VAPID configuration status (without exposing private key) +func LogVAPIDConfiguration() { + if vapidPrivateKey != "" && vapidPublicKey != "" && vapidSubject != "" { + log.Printf("- VAPID configuration loaded (Subject: %s)", vapidSubject) + } else { + log.Printf("- VAPID configuration incomplete - push notifications disabled") + if vapidPrivateKey == "" { + log.Printf(" Missing: VAPID_PRIVATE_KEY") + } + if vapidPublicKey == "" { + log.Printf(" Missing: VAPID_PUBLIC_KEY") + } + if vapidSubject == "" { + log.Printf(" Missing: VAPID_SUBJECT") + } + } +} + +// CleanupExpiredSubscriptions removes subscriptions that are no longer valid +func CleanupExpiredSubscriptions() error { + subscriptions, err := GetAllPushSubscriptions() + if err != nil { + return err + } + + removedCount := 0 + for _, storedSub := range subscriptions { + // Try to send a test notification to check if subscription is still valid + testMessage := PushMessage{ + Type: "test", + Timestamp: getCurrentTimestamp(), + } + + webpushSub := &webpush.Subscription{ + Endpoint: storedSub.Endpoint, + Keys: webpush.Keys{ + P256dh: storedSub.P256dhKey, + Auth: storedSub.AuthKey, + }, + } + + if err := SendPushNotification(webpushSub, testMessage); err != nil { + if err.Error() == "subscription expired and removed" { + removedCount++ + } + } + } + + if removedCount > 0 { + log.Printf("- Cleaned up %d expired push subscriptions", removedCount) + } + + return nil +} + +// DevicePushStatus tracks push notification status for a device +type DevicePushStatus struct { + DeviceName string `json:"device_name"` + LastPushSent int64 `json:"last_push_sent"` + PushCount int64 `json:"push_count"` + LastHeartbeatTime int64 `json:"last_heartbeat_time"` +} + +// StartPushMonitoring starts the background service that monitors device inactivity +// and sends push notifications when devices haven't sent heartbeats for too long +func StartPushMonitoring() { + if ValidateVAPIDConfiguration() != nil { + log.Printf("- Push monitoring disabled: VAPID not configured") + return + } + + log.Printf("- Starting push monitoring (check every %v, threshold %v)", + pushCheckInterval, pushInactivityThreshold) + + ticker := time.NewTicker(pushCheckInterval) + go func() { + for { + select { + case <-ticker.C: + checkInactiveDevicesAndPush() + } + } + }() +} + +// checkInactiveDevicesAndPush checks all devices for inactivity and sends push notifications as needed +func checkInactiveDevicesAndPush() { + // Get all devices with push subscriptions + subscriptions, err := GetAllPushSubscriptions() + if err != nil { + log.Printf("- Error getting push subscriptions for monitoring: %v", err) + return + } + + if len(subscriptions) == 0 { + return // No devices to monitor + } + + currentTime := time.Now().Unix() + devicesChecked := 0 + pushesTriggered := 0 + + // Group subscriptions by device to avoid duplicate checks + deviceSubscriptions := make(map[string][]StoredPushSubscription) + for _, sub := range subscriptions { + deviceKey := sub.DeviceName + "|" + sub.AuthToken + deviceSubscriptions[deviceKey] = append(deviceSubscriptions[deviceKey], sub) + } + + for deviceKey := range deviceSubscriptions { + parts := strings.SplitN(deviceKey, "|", 2) + if len(parts) != 2 { + continue + } + deviceName := parts[0] + authToken := parts[1] + + // Get the device's last heartbeat time + lastHeartbeat := getLastHeartbeatForDevice(deviceName) + if lastHeartbeat == nil { + continue // No heartbeat data for this device + } + + // Calculate time since last heartbeat + timeSinceHeartbeat := time.Duration(currentTime-lastHeartbeat.Timestamp) * time.Second + + // Check if device is inactive beyond threshold + if timeSinceHeartbeat > pushInactivityThreshold { + // Check if we've already sent a push recently to avoid spam + if shouldSendPushNotification(deviceName, authToken, lastHeartbeat.Timestamp) { + log.Printf("- Device %s inactive for %v (threshold: %v), sending push notification", + deviceName, timeSinceHeartbeat.Round(time.Minute), pushInactivityThreshold) + + // Send push notification to wake up the device + sentCount, err := SendHeartbeatCheckToDevice(deviceName, authToken) + if err != nil { + log.Printf("- Error sending push to inactive device %s: %v", deviceName, err) + } else { + pushesTriggered += sentCount + // Record that we sent a push for this device + recordPushSent(deviceName, authToken, currentTime) + } + } + } + + devicesChecked++ + } + + if pushesTriggered > 0 { + log.Printf("- Push monitoring: checked %d devices, triggered %d push notifications", + devicesChecked, pushesTriggered) + } +} + +// getLastHeartbeatForDevice gets the last heartbeat for a specific device +func getLastHeartbeatForDevice(deviceName string) *HeartbeatBeat { + // Search through heartbeatDevices for the specific device + if heartbeatDevices == nil { + return nil + } + + for _, device := range *heartbeatDevices { + if device.DeviceName == deviceName { + return &device.LastBeat + } + } + + return nil +} + +// shouldSendPushNotification determines if we should send a push notification to avoid spam +func shouldSendPushNotification(deviceName, authToken string, lastHeartbeatTime int64) bool { + status := getPushStatus(deviceName, authToken) + currentTime := time.Now().Unix() + + // Don't send push if we sent one recently (within 15 minutes) + timeSinceLastPush := time.Duration(currentTime-status.LastPushSent) * time.Second + if timeSinceLastPush < 15*time.Minute { + return false + } + + // Don't send push if heartbeat time hasn't changed (device might be offline) + if status.LastHeartbeatTime == lastHeartbeatTime && status.PushCount > 0 { + // Only send another push if it's been a long time since the last one + return timeSinceLastPush > 4*time.Hour + } + + return true +} + +// getPushStatus retrieves the push status for a device +func getPushStatus(deviceName, authToken string) DevicePushStatus { + statusKey := fmt.Sprintf("push_status:%s:%s", deviceName, authToken) + + res, err := rjh.JSONGet(statusKey, ".") + if err != nil { + // Return default status if not found + return DevicePushStatus{ + DeviceName: deviceName, + LastPushSent: 0, + PushCount: 0, + LastHeartbeatTime: 0, + } + } + + var status DevicePushStatus + if err = json.Unmarshal(res.([]byte), &status); err != nil { + log.Printf("- Error unmarshaling push status for %s: %v", deviceName, err) + return DevicePushStatus{ + DeviceName: deviceName, + LastPushSent: 0, + PushCount: 0, + LastHeartbeatTime: 0, + } + } + + return status +} + +// recordPushSent records that a push notification was sent to a device +func recordPushSent(deviceName, authToken string, timestamp int64) { + status := getPushStatus(deviceName, authToken) + status.LastPushSent = timestamp + status.PushCount++ + // Don't update LastHeartbeatTime here - keep the actual last heartbeat time + + statusKey := fmt.Sprintf("push_status:%s:%s", deviceName, authToken) + if _, err := rjh.JSONSet(statusKey, ".", status); err != nil { + log.Printf("- Error recording push status for %s: %v", deviceName, err) + } +} + +// GetPushMonitoringStats returns statistics about the push monitoring system +func GetPushMonitoringStats() map[string]interface{} { + subscriptions, err := GetAllPushSubscriptions() + if err != nil { + return map[string]interface{}{ + "error": "Failed to get subscriptions", + } + } + + currentTime := time.Now().Unix() + activeDevices := 0 + inactiveDevices := 0 + totalPushes := int64(0) + + // Group by device + deviceMap := make(map[string]bool) + for _, sub := range subscriptions { + deviceKey := sub.DeviceName + "|" + sub.AuthToken + if _, exists := deviceMap[deviceKey]; !exists { + deviceMap[deviceKey] = true + + parts := strings.SplitN(deviceKey, "|", 2) + if len(parts) == 2 { + deviceName := parts[0] + authToken := parts[1] + + lastHeartbeat := getLastHeartbeatForDevice(deviceName) + if lastHeartbeat != nil { + timeSinceHeartbeat := time.Duration(currentTime-lastHeartbeat.Timestamp) * time.Second + if timeSinceHeartbeat > pushInactivityThreshold { + inactiveDevices++ + } else { + activeDevices++ + } + } + + status := getPushStatus(deviceName, authToken) + totalPushes += status.PushCount + } + } + } + + return map[string]interface{}{ + "total_subscriptions": len(subscriptions), + "unique_devices": len(deviceMap), + "active_devices": activeDevices, + "inactive_devices": inactiveDevices, + "total_pushes_sent": totalPushes, + "check_interval": pushCheckInterval.String(), + "inactivity_threshold": pushInactivityThreshold.String(), + "vapid_configured": ValidateVAPIDConfiguration() == nil, + } +} \ No newline at end of file diff --git a/push_db.go b/push_db.go new file mode 100644 index 0000000..643f55a --- /dev/null +++ b/push_db.go @@ -0,0 +1,361 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "time" + + webpush "github.com/SherClockHolmes/webpush-go" + "github.com/valyala/fasthttp" +) + +var ( + // VAPID configuration + vapidPublicKey string + vapidPrivateKey string + vapidSubject string + + // Push monitoring configuration + pushCheckInterval = 5 * time.Minute + pushInactivityThreshold = 10 * time.Minute +) + +// StoredPushSubscription represents a push subscription stored in Redis +type StoredPushSubscription struct { + Endpoint string `json:"endpoint"` + P256dhKey string `json:"p256dh"` + AuthKey string `json:"auth"` + DeviceName string `json:"device_name"` + AuthToken string `json:"auth_token"` + CreatedAt int64 `json:"created_at"` + UpdatedAt int64 `json:"updated_at"` +} + +// PushSubscribeRequest represents the request from the PWA +type PushSubscribeRequest struct { + Endpoint string `json:"endpoint"` + Keys map[string]interface{} `json:"keys"` + Encoding string `json:"encoding,omitempty"` + DeviceName string `json:"deviceName"` +} + +// InitPushConfiguration loads VAPID keys from environment variables +func InitPushConfiguration() { + vapidPublicKey = os.Getenv("VAPID_PUBLIC_KEY") + vapidPrivateKey = os.Getenv("VAPID_PRIVATE_KEY") + vapidSubject = os.Getenv("VAPID_SUBJECT") + + if vapidSubject == "" { + vapidSubject = "mailto:admin@josie.health" + } + + // Load push monitoring configuration from environment + if interval := os.Getenv("PUSH_CHECK_INTERVAL"); interval != "" { + if d, err := time.ParseDuration(interval); err == nil { + pushCheckInterval = d + } + } + + if threshold := os.Getenv("PUSH_INACTIVITY_THRESHOLD"); threshold != "" { + if d, err := time.ParseDuration(threshold); err == nil { + pushInactivityThreshold = d + } + } + + LogVAPIDConfiguration() +} + +// SavePushSubscription stores a push subscription in Redis +func SavePushSubscription(sub StoredPushSubscription) error { + key := fmt.Sprintf("push_subscription:%s:%s", sub.DeviceName, sub.AuthToken) + sub.UpdatedAt = time.Now().Unix() + + if sub.CreatedAt == 0 { + sub.CreatedAt = sub.UpdatedAt + } + + _, err := rjh.JSONSet(key, ".", sub) + if err != nil { + return fmt.Errorf("error saving push subscription: %v", err) + } + + // Also add to a set for easy retrieval of all subscriptions + setKey := fmt.Sprintf("push_subscriptions:%s", sub.AuthToken) + ctx := context.Background() + if _, err := rdb.SAdd(ctx, setKey, key).Result(); err != nil { + log.Printf("- Warning: failed to add subscription to set: %v", err) + } + + return nil +} + +// GetPushSubscriptionsByDevice retrieves all push subscriptions for a device +func GetPushSubscriptionsByDevice(deviceName, authToken string) ([]StoredPushSubscription, error) { + key := fmt.Sprintf("push_subscription:%s:%s", deviceName, authToken) + + res, err := rjh.JSONGet(key, ".") + if err != nil { + return nil, fmt.Errorf("error getting push subscription: %v", err) + } + + if res == nil { + return []StoredPushSubscription{}, nil + } + + var sub StoredPushSubscription + if err := json.Unmarshal(res.([]byte), &sub); err != nil { + return nil, fmt.Errorf("error unmarshaling push subscription: %v", err) + } + + return []StoredPushSubscription{sub}, nil +} + +// GetAllPushSubscriptions retrieves all push subscriptions from Redis +func GetAllPushSubscriptions() ([]StoredPushSubscription, error) { + // Get all push subscription keys + ctx := context.Background() + keys, err := rdb.Keys(ctx, "push_subscription:*").Result() + if err != nil { + return nil, fmt.Errorf("error getting push subscription keys: %v", err) + } + + var subscriptions []StoredPushSubscription + for _, key := range keys { + res, err := rjh.JSONGet(key, ".") + if err != nil { + log.Printf("- Warning: failed to get subscription %s: %v", key, err) + continue + } + + if res == nil { + continue + } + + var sub StoredPushSubscription + if err := json.Unmarshal(res.([]byte), &sub); err != nil { + log.Printf("- Warning: failed to unmarshal subscription %s: %v", key, err) + continue + } + + subscriptions = append(subscriptions, sub) + } + + return subscriptions, nil +} + +// RemovePushSubscription removes a push subscription by endpoint +func RemovePushSubscription(endpoint string) error { + // Find and remove the subscription + ctx := context.Background() + keys, err := rdb.Keys(ctx, "push_subscription:*").Result() + if err != nil { + return fmt.Errorf("error getting push subscription keys: %v", err) + } + + for _, key := range keys { + res, err := rjh.JSONGet(key, ".") + if err != nil { + continue + } + + if res == nil { + continue + } + + var sub StoredPushSubscription + if err := json.Unmarshal(res.([]byte), &sub); err != nil { + continue + } + + if sub.Endpoint == endpoint { + // Remove from Redis + ctx := context.Background() + if _, err := rdb.Del(ctx, key).Result(); err != nil { + return fmt.Errorf("error removing subscription: %v", err) + } + + // Remove from set + setKey := fmt.Sprintf("push_subscriptions:%s", sub.AuthToken) + if _, err := rdb.SRem(ctx, setKey, key).Result(); err != nil { + log.Printf("- Warning: failed to remove subscription from set: %v", err) + } + + log.Printf("- Removed push subscription for %s", sub.DeviceName) + return nil + } + } + + return fmt.Errorf("subscription not found") +} + +// HandlePushSubscribe handles the push subscription request from the PWA +func HandlePushSubscribe(ctx *fasthttp.RequestCtx) { + var req PushSubscribeRequest + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + log.Printf("- Error unmarshalling push subscription: %v", err) + ctx.SetStatusCode(400) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Invalid subscription data"}`) + return + } + + // Validate required fields + if req.Endpoint == "" || req.Keys == nil { + ctx.SetStatusCode(400) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Missing required fields"}`) + return + } + + // Extract keys + p256dh, ok1 := req.Keys["p256dh"].(string) + auth, ok2 := req.Keys["auth"].(string) + + if !ok1 || !ok2 { + ctx.SetStatusCode(400) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Invalid keys format"}`) + return + } + + // Get auth token from headers + authToken := string(ctx.Request.Header.Peek("Auth")) + deviceName := string(ctx.Request.Header.Peek("Device")) + + if deviceName == "" { + deviceName = req.DeviceName + } + if deviceName == "" { + deviceName = "Unknown Device" + } + + // Store subscription + sub := StoredPushSubscription{ + Endpoint: req.Endpoint, + P256dhKey: p256dh, + AuthKey: auth, + DeviceName: deviceName, + AuthToken: authToken, + CreatedAt: time.Now().Unix(), + UpdatedAt: time.Now().Unix(), + } + + if err := SavePushSubscription(sub); err != nil { + log.Printf("- Error saving push subscription: %v", err) + ctx.SetStatusCode(500) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Failed to save subscription"}`) + return + } + + log.Printf("- Push subscription registered for device: %s", deviceName) + + ctx.SetStatusCode(200) + ctx.SetContentType(jsonMime) + response := map[string]interface{}{ + "success": true, + "message": "Push subscription registered successfully", + "device": deviceName, + } + + responseJSON, _ := json.Marshal(response) + ctx.Write(responseJSON) +} + +// HandlePushUnsubscribe handles the push unsubscription request +func HandlePushUnsubscribe(ctx *fasthttp.RequestCtx) { + var req struct { + Endpoint string `json:"endpoint"` + DeviceName string `json:"deviceName"` + } + + if err := json.Unmarshal(ctx.PostBody(), &req); err != nil { + ctx.SetStatusCode(400) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Invalid request data"}`) + return + } + + if err := RemovePushSubscription(req.Endpoint); err != nil { + log.Printf("- Error removing push subscription: %v", err) + // Don't return error to client if subscription doesn't exist + if err.Error() != "subscription not found" { + ctx.SetStatusCode(500) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Failed to remove subscription"}`) + return + } + } + + ctx.SetStatusCode(200) + ctx.SetContentType(jsonMime) + response := map[string]interface{}{ + "success": true, + "message": "Push subscription removed successfully", + "device": req.DeviceName, + } + + responseJSON, _ := json.Marshal(response) + ctx.Write(responseJSON) +} + +// HandlePushTest sends a test push notification +func HandlePushTest(ctx *fasthttp.RequestCtx) { + deviceName := string(ctx.QueryArgs().Peek("device")) + authToken := string(ctx.Request.Header.Peek("Auth")) + + if deviceName == "" { + ctx.SetStatusCode(400) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"Device parameter required"}`) + return + } + + message := PushMessage{ + Type: "test", + DeviceName: deviceName, + Timestamp: time.Now().Unix(), + } + + subscriptions, err := GetPushSubscriptionsByDevice(deviceName, authToken) + if err != nil || len(subscriptions) == 0 { + ctx.SetStatusCode(404) + ctx.SetContentType(jsonMime) + ctx.WriteString(`{"error":"No subscription found for device"}`) + return + } + + sub := subscriptions[0] + webpushSub := &webpush.Subscription{ + Endpoint: sub.Endpoint, + Keys: webpush.Keys{ + P256dh: sub.P256dhKey, + Auth: sub.AuthKey, + }, + } + + if err := SendPushNotification(webpushSub, message); err != nil { + ctx.SetStatusCode(500) + ctx.SetContentType(jsonMime) + response := map[string]interface{}{ + "error": fmt.Sprintf("Failed to send push notification: %v", err), + } + responseJSON, _ := json.Marshal(response) + ctx.Write(responseJSON) + return + } + + ctx.SetStatusCode(200) + ctx.SetContentType(jsonMime) + response := map[string]interface{}{ + "success": true, + "message": "Test push notification sent", + "device": deviceName, + } + + responseJSON, _ := json.Marshal(response) + ctx.Write(responseJSON) +} \ No newline at end of file