diff --git a/.gitignore b/.gitignore index ea65fde..525a49b 100644 --- a/.gitignore +++ b/.gitignore @@ -214,3 +214,6 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/windows,macos,linux,goland,go .env +vapid_public_key +vapid_private_key +data.sqlite diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..37f9176 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,15 @@ + + + + + sqlite.xerial + true + org.sqlite.JDBC + jdbc:sqlite:$PROJECT_DIR$/data.sqlite + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/.idea/runConfigurations/Generate_VAPID.xml b/.idea/runConfigurations/Generate_VAPID.xml new file mode 100644 index 0000000..327648a --- /dev/null +++ b/.idea/runConfigurations/Generate_VAPID.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/runConfigurations/go_build_code_nym_sh_7am.xml b/.idea/runConfigurations/go_build_code_nym_sh_7am.xml new file mode 100644 index 0000000..135d74f --- /dev/null +++ b/.idea/runConfigurations/go_build_code_nym_sh_7am.xml @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..cd75f56 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/generate_vapid.go b/generate_vapid.go new file mode 100644 index 0000000..83aab1e --- /dev/null +++ b/generate_vapid.go @@ -0,0 +1,30 @@ +package main + +import ( + "github.com/SherClockHolmes/webpush-go" + "log" + "os" +) + +func main() { + pub, priv, err := webpush.GenerateVAPIDKeys() + if err != nil { + log.Fatal(err) + } + + f, err := os.OpenFile("vapid_public_key", os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal(err) + } + defer f.Close() + + f.Write([]byte(pub)) + + f, err = os.OpenFile("vapid_private_key", os.O_CREATE|os.O_WRONLY, 0644) + if err != nil { + log.Fatal(err) + } + defer f.Close() + + f.Write([]byte(priv)) +} diff --git a/go.mod b/go.mod index 5f714fd..bf49a69 100644 --- a/go.mod +++ b/go.mod @@ -1,19 +1,21 @@ -module code.nym.sh/weatherboy +module code.nym.sh/7am go 1.24.3 require ( - github.com/coder/websocket v1.8.13 + github.com/SherClockHolmes/webpush-go v1.4.0 github.com/go-co-op/gocron/v2 v2.16.1 github.com/joho/godotenv v1.5.1 - golang.org/x/time v0.11.0 google.golang.org/genai v1.4.0 + modernc.org/sqlite v1.37.0 ) require ( cloud.google.com/go v0.116.0 // indirect cloud.google.com/go/auth v0.9.3 // indirect cloud.google.com/go/compute/metadata v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/google/s2a-go v0.1.8 // indirect @@ -21,13 +23,20 @@ require ( github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect go.opencensus.io v0.24.0 // indirect - golang.org/x/crypto v0.27.0 // indirect + golang.org/x/crypto v0.38.0 // indirect + golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect golang.org/x/net v0.29.0 // indirect - golang.org/x/sys v0.25.0 // indirect - golang.org/x/text v0.18.0 // indirect + golang.org/x/sys v0.33.0 // indirect + golang.org/x/text v0.25.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect google.golang.org/grpc v1.66.2 // indirect google.golang.org/protobuf v1.34.2 // indirect + modernc.org/libc v1.62.1 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.9.1 // indirect ) diff --git a/go.sum b/go.sum index 9d089e6..b376c6c 100644 --- a/go.sum +++ b/go.sum @@ -6,20 +6,25 @@ cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842Bg cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY= cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= -github.com/coder/websocket v1.8.13 h1:f3QZdXy7uGVz+4uCJy2nTZyM0yTBj8yANEHhqlXZ9FE= -github.com/coder/websocket v1.8.13/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/go-co-op/gocron/v2 v2.16.1 h1:ux/5zxVRveCaCuTtNI3DiOk581KC1KpJbpJFYUEVYwo= github.com/go-co-op/gocron/v2 v2.16.1/go.mod h1:opexeOFy5BplhsKdA7bzY9zeYih8I8/WNJ4arTIFPVc= +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -42,6 +47,8 @@ github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/ github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= @@ -55,9 +62,15 @@ github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= @@ -68,49 +81,111 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= -golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A= -golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +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/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= +golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8= +golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= +golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +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/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/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 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-20201110031124-69a78807bb2b/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-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.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= -golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.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/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 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-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34= -golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.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.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= +golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +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.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224= -golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= -golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +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.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= +golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +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.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/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU= +golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= @@ -145,3 +220,27 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +modernc.org/cc/v4 v4.25.2 h1:T2oH7sZdGvTaie0BRNFbIYsabzCxUQg8nLqCdQ2i0ic= +modernc.org/cc/v4 v4.25.2/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.25.1 h1:TFSzPrAGmDsdnhT9X2UrcPMI3N/mJ9/X9ykKXwLhDsU= +modernc.org/ccgo/v4 v4.25.1/go.mod h1:njjuAYiPflywOOrm3B7kCB444ONP5pAVr8PIEoE0uDw= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/libc v1.62.1 h1:s0+fv5E3FymN8eJVmnk0llBe6rOxCu/DEU+XygRbS8s= +modernc.org/libc v1.62.1/go.mod h1:iXhATfJQLjG3NWy56a6WVU73lWOcdYVxsvwCgoPljuo= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.9.1 h1:V/Z1solwAVmMW1yttq3nDdZPJqV1rM05Ccq6KMSZ34g= +modernc.org/memory v1.9.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.37.0 h1:s1TMe7T3Q3ovQiK2Ouz4Jwh7dw4ZDqbebSDTlSJdfjI= +modernc.org/sqlite v1.37.0/go.mod h1:5YiWv+YviqGMuGw4V+PNplcyaJ5v+vQd7TQOgkACoJM= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index b4d2cf9..bf2b9a8 100644 --- a/main.go +++ b/main.go @@ -2,24 +2,27 @@ package main import ( "context" + "database/sql" "embed" _ "embed" + "encoding/json" + "errors" "fmt" - "github.com/coder/websocket" + "github.com/SherClockHolmes/webpush-go" "github.com/go-co-op/gocron/v2" + "github.com/google/uuid" "github.com/joho/godotenv" - "golang.org/x/time/rate" "google.golang.org/genai" "html/template" "io" "log" "mime" + _ "modernc.org/sqlite" "net/http" "os" "path/filepath" "strings" "sync" - "sync/atomic" "time" ) @@ -37,21 +40,39 @@ type pageTemplate struct { summary *template.Template } -type state struct { - ctx context.Context - apiKey apiKey - template pageTemplate - summaries sync.Map - summaryChans map[string]chan string - genai *genai.Client - subscriberCount atomic.Int64 -} - type summaryTemplateData struct { Summary string Location string } +type updateSubscription struct { + Subscription webpush.Subscription `json:"subscription"` + Locations []string `json:"locations"` +} + +type registeredSubscription struct { + ID uuid.UUID `json:"id"` + Subscription *webpush.Subscription `json:"-"` + Locations []string `json:"locations"` +} + +type state struct { + ctx context.Context + db *sql.DB + genai *genai.Client + apiKey apiKey + template pageTemplate + + summaries sync.Map + summaryChans map[string]chan string + + subscriptions map[string][]registeredSubscription + subscriptionsMutex sync.Mutex + + vapidPublicKey string + vapidPrivateKey string +} + //go:embed web var webDir embed.FS @@ -72,6 +93,11 @@ func main() { log.Fatalln("Please create a .env file using the provided template!") } + db, err := initDB() + if err != nil { + log.Fatalf("failed to initialize db: %e\n", err) + } + ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -88,6 +114,7 @@ func main() { state := state{ ctx: ctx, + db: db, apiKey: apiKey{ openWeatherMap: os.Getenv("OPEN_WEATHER_MAP_API_KEY"), }, @@ -97,6 +124,11 @@ func main() { summaries: sync.Map{}, summaryChans: map[string]chan string{}, genai: genaiClient, + + subscriptions: map[string][]registeredSubscription{}, + + vapidPublicKey: os.Getenv("VAPID_PUBLIC_KEY_BASE64"), + vapidPrivateKey: os.Getenv("VAPID_PRIVATE_KEY_BASE64"), } var schedulers []gocron.Scheduler @@ -114,17 +146,26 @@ func main() { _, err = s.NewJob( gocron.DurationJob(time.Minute), - gocron.NewTask(updateSummaries, &state, locKey, &loc)) + gocron.NewTask(updateSummaries, &state, locKey, &loc), + gocron.WithStartAt(gocron.WithStartImmediately()), + ) if err != nil { log.Fatal(err) } schedulers = append(schedulers, s) - state.summaryChans[locKey] = make(chan string) + c := make(chan string) + + state.subscriptions[locKey] = []registeredSubscription{} + state.summaryChans[locKey] = c + + go listenForSummaryUpdates(&state, locKey) s.Start() } + loadSubscriptions(&state) + http.HandleFunc("/", handleHTTPRequest(&state)) http.ListenAndServe(":8080", nil) @@ -137,28 +178,83 @@ func handleHTTPRequest(state *state) http.HandlerFunc { return func(writer http.ResponseWriter, request *http.Request) { path := strings.TrimPrefix(request.URL.Path, "/") - switch path { - case "": - index, _ := webDir.ReadFile("web/index.html") - writer.Write(index) - - case "ws": - conn, err := websocket.Accept(writer, request, nil) - if err != nil { - log.Printf("error accepting incoming ws connection: %e\n", err) + if path == "" { + if request.Method == "" || request.Method == "GET" { + index, _ := webDir.ReadFile("web/index.html") + writer.Write(index) + } else { + writer.WriteHeader(http.StatusMethodNotAllowed) } - defer conn.CloseNow() + } else if path == "vapid" { + if request.Method == "" || request.Method == "GET" { + writer.Write([]byte(state.vapidPublicKey)) + } else { + writer.WriteHeader(http.StatusMethodNotAllowed) + } + } else if strings.HasPrefix(path, "registrations") { + if path == "registrations" && request.Method == "POST" { + defer request.Body.Close() - log.Println("accepted incoming websocket connection") + update := updateSubscription{} + err := json.NewDecoder(request.Body).Decode(&update) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } - locKey := request.URL.Query().Get("location") - if c, ok := state.summaryChans[locKey]; ok { - state.subscriberCount.Add(1) - sendSummaryUpdates(state, c, conn) - state.subscriberCount.Add(-1) + reg, err := registerSubscription(state, &update) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + + err = json.NewEncoder(writer).Encode(reg) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + } + } else if request.Method == "PATCH" { + parts := strings.Split(path, "/") + if len(parts) < 2 { + writer.WriteHeader(http.StatusMethodNotAllowed) + return + } + + regID, err := uuid.Parse(parts[1]) + if err != nil { + writer.WriteHeader(http.StatusNotFound) + return + } + + defer request.Body.Close() + + update := updateSubscription{} + err = json.NewDecoder(request.Body).Decode(&update) + if err != nil { + writer.WriteHeader(http.StatusBadRequest) + return + } + + reg, err := updateRegisteredSubscription(state, regID, &update) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + writer.WriteHeader(http.StatusNotFound) + } else { + writer.WriteHeader(http.StatusInternalServerError) + } + return + } + + json.NewEncoder(writer).Encode(reg) + } else { + writer.WriteHeader(http.StatusMethodNotAllowed) + } + + } else { + if request.Method != "" && request.Method != "GET" { + writer.WriteHeader(http.StatusMethodNotAllowed) + return } - default: summary, ok := state.summaries.Load(path) if ok { state.template.summary.Execute(writer, summaryTemplateData{summary.(string), path}) @@ -178,34 +274,113 @@ func handleHTTPRequest(state *state) http.HandlerFunc { } } -func sendSummaryUpdates(state *state, c <-chan string, conn *websocket.Conn) { - l := rate.NewLimiter(rate.Every(time.Millisecond*100), 10) - ctx, cancel := context.WithCancel(state.ctx) - defer cancel() +func initDB() (*sql.DB, error) { + db, err := sql.Open("sqlite", "file:data.sqlite") + if err != nil { + log.Fatalln("failed to initialize database") + } - for { - err := l.Wait(ctx) + _, err = db.Exec(` + CREATE TABLE IF NOT EXISTS subscriptions( + id TEXT PRIMARY KEY, + locations TEXT NOT NULL, + subscription_json TEXT NOT NULL + ); + `) + if err != nil { + return nil, err + } + + return db, nil +} + +func loadSubscriptions(state *state) error { + rows, err := state.db.Query(`SELECT id, locations, subscription_json FROM subscriptions;`) + if err != nil { + return err + } + + for rows.Next() { + var id string + var locations string + var j string + + err := rows.Scan(&id, &locations, &j) if err != nil { - return + continue } - select { - case summary := <-c: - log.Println("summary updated. sending updates via sockets...") + s := webpush.Subscription{} + err = json.Unmarshal([]byte(j), &s) + if err != nil { + continue + } - w, err := conn.Writer(ctx, websocket.MessageText) - if err != nil { - return - } - _, err = w.Write([]byte(summary)) - if err != nil { - return - } - w.Close() - case <-ctx.Done(): - return + reg := registeredSubscription{ + ID: uuid.MustParse(id), + Locations: strings.Split(locations, ","), + Subscription: &s, + } + + for _, l := range reg.Locations { + state.subscriptions[l] = append(state.subscriptions[l], reg) } } + + return nil +} + +func updateRegisteredSubscription(state *state, id uuid.UUID, update *updateSubscription) (*registeredSubscription, error) { + j, err := json.Marshal(update.Subscription) + if err != nil { + return nil, err + } + + _, err = state.db.Exec( + "UPDATE subscriptions SET subscription_json = ?, locations = ? WHERE id = ?", + string(j), strings.Join(update.Locations, ","), id, + ) + if err != nil { + return nil, err + } + + return ®isteredSubscription{ + ID: id, + Subscription: &update.Subscription, + Locations: update.Locations, + }, nil +} + +func registerSubscription(state *state, sub *updateSubscription) (*registeredSubscription, error) { + j, err := json.Marshal(sub.Subscription) + if err != nil { + return nil, err + } + + id, err := uuid.NewV7() + if err != nil { + return nil, err + } + + _, err = state.db.Exec( + "INSERT INTO subscriptions (id, locations, subscription_json) VALUES (?, ?, ?);", + id, strings.Join(sub.Locations, ","), string(j), + ) + if err != nil { + return nil, err + } + + reg := registeredSubscription{ + ID: id, + Subscription: &sub.Subscription, + Locations: sub.Locations, + } + + for _, l := range sub.Locations { + state.subscriptions[l] = append(state.subscriptions[l], reg) + } + + return ®, nil } func updateSummaries(state *state, locKey string, loc *location) { @@ -239,9 +414,32 @@ func updateSummaries(state *state, locKey string, loc *location) { c := state.summaryChans[locKey] state.summaries.Store(locKey, summary) - if state.subscriberCount.Load() > 0 { + if len(state.subscriptions[locKey]) > 0 { c <- summary } log.Printf("updated summary for %v successfully\n", locKey) } + +func listenForSummaryUpdates(state *state, locKey string) { + c := state.summaryChans[locKey] + for { + select { + case summary := <-c: + log.Printf("sending summary for %v to subscribers...\n", locKey) + for _, sub := range state.subscriptions[locKey] { + _, err := webpush.SendNotificationWithContext(state.ctx, []byte(summary), sub.Subscription, &webpush.Options{ + VAPIDPublicKey: state.vapidPublicKey, + VAPIDPrivateKey: state.vapidPrivateKey, + TTL: 30, + }) + if err != nil { + log.Printf("failed to send notification %e\n", err) + } + } + + case <-state.ctx.Done(): + return + } + } +} diff --git a/web/summary.js b/web/summary.js index 1119560..3caaca2 100644 --- a/web/summary.js +++ b/web/summary.js @@ -1,39 +1,104 @@ -const KEY_UPDATES_ENABLED = "updatesEnabled" +const KEY_SUBSCRIPTION = "subscription" const canReceiveUpdates = "Notification" in window && "serviceWorker" in navigator const getSummaryButton = document.getElementById("get-summary-btn") const loc = getSummaryButton.dataset.loc getSummaryButton.style.display = "none" -console.log("can receive updates?", canReceiveUpdates) - -if (canReceiveUpdates) { +async function main() { window.addEventListener("load", () => { navigator.serviceWorker.register("/sw.js") }) - navigator.serviceWorker.ready.then((reg) => { - if (Notification.permission === "granted" && localStorage.getItem(KEY_UPDATES_ENABLED) === "true") { - getSummaryButton.innerText = "Stop updates" - reg.active.postMessage(loc) - } else { - getSummaryButton.innerText = "Get daily updates at 7am" - } + const reg = await navigator.serviceWorker.ready - getSummaryButton.addEventListener("click", async () => { - const currentlyEnabled = localStorage.getItem(KEY_UPDATES_ENABLED) === "true" - const worker = await navigator.serviceWorker.ready - if (currentlyEnabled) { - localStorage.removeItem(KEY_UPDATES_ENABLED) - worker.active.postMessage("cancel") - } else if (await Notification.requestPermission()) { - localStorage.setItem(KEY_UPDATES_ENABLED, "true") - worker.active.postMessage(loc) - } else { - console.log("notification denied") - } - }) + const existingSubscriptionJson = localStorage.getItem(KEY_SUBSCRIPTION) + const existingSubscription = existingSubscriptionJson ? JSON.parse(existingSubscriptionJson) : null - getSummaryButton.style.display = "block" - }) + if (existingSubscription?.locations?.includes(loc) ?? false) { + getSummaryButton.innerText = "Stop updates" + reg.active.postMessage(loc) + } else { + getSummaryButton.innerText = "Get daily updates at 7am" + } + + getSummaryButton.addEventListener("click", onButtonClick) + getSummaryButton.style.display = "block" +} + +async function onButtonClick() { + const reg = await navigator.serviceWorker.ready + + const existingSubscriptionJson = localStorage.getItem(KEY_SUBSCRIPTION) + const existingSubscription = existingSubscriptionJson ? JSON.parse(existingSubscriptionJson) : null + const currentlyEnabled = existingSubscription?.locations?.includes(loc) ?? false + + if (currentlyEnabled) { + await reg.pushManager.getSubscription().then((sub) => sub?.unsubscribe()) + localStorage.removeItem(KEY_SUBSCRIPTION) + getSummaryButton.innerText = "Get daily updates at 7am" + } else { + const worker = await navigator.serviceWorker.ready + + try { + const publicKey = await fetch("/vapid").then((res) => { + if (res.status === 200) { + return res.text() + } + throw new Error(`${res.status}`) + }) + const pushSub = await worker.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: publicKey + }).catch((error) => { + console.error(error) + }) + + let newSubscription + if (existingSubscription) { + newSubscription = await fetch(`/registrations/${existingSubscription.id}`, { + method: "PATCH", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + subscription: pushSub, + locations: [...existingSubscription.locations, loc] + }) + }).then((res) => { + if (res.status === 200) { + return res.json() + } + throw new Error(`${res.status}`) + }) + } else { + newSubscription = await fetch("/registrations", { + method: "POST", + headers: { + "Content-Type": "application/json" + }, + body: JSON.stringify({ + subscription: pushSub, + locations: [loc] + }) + }).then((res) => { + if (res.status === 200) { + return res.json() + } + throw new Error(`${res.status}`) + }) + } + + localStorage.setItem(KEY_SUBSCRIPTION, JSON.stringify(newSubscription)) + + getSummaryButton.innerText = "Stop updates" + } catch (error) { + console.log(error) + } + } + +} + +if (canReceiveUpdates) { + main() } diff --git a/web/sw.js b/web/sw.js index c5c13c5..adc12d9 100644 --- a/web/sw.js +++ b/web/sw.js @@ -1,47 +1,17 @@ -let ws -let shouldRetry = false +self.addEventListener('install', function (event) { + event.waitUntil(self.skipWaiting()); +}); -function start(location) { - shouldRetry = true - const port = self.location.port ? `:${self.location.port}` : "" - const socket = new WebSocket(`ws://${self.location.hostname}${port}/ws?location=${location}`) - socket.onopen = () => { - ws = socket - } - socket.onclose = () => { - reconnect() - } - socket.onerror = () => { - reconnect() - } - socket.onmessage = (event) => { - self.registration.showNotification("7am weather summary", { - body: event.data - }) - } -} +self.addEventListener('activate', function (event) { + event.waitUntil(self.clients.claim()); +}); -function cancel() { - shouldRetry = false - if (ws) { - ws.close() - ws = null +self.addEventListener("push", (event) => { + if (event.data) { + event.waitUntil( + self.registration.showNotification("7am weather summary", { + body: event.data.text() + }) + ) } -} - -async function reconnect() { - while (shouldRetry) { - await new Promise((resolve) => { - setTimeout(resolve, 5 * 60 * 1000) - }) - start(location) - } -} - -self.addEventListener("message", (event) => { - if (event.data === "cancel") { - cancel() - } else { - start(event.data) - } -}) \ No newline at end of file +})