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
+})