Add Web Push notifications (self-hosted VAPID, E2EE-preserving)
- push.go: self-generated VAPID keypair (no third-party account), subscription
storage, and send-on-new-message that pushes the SAME ciphertext blob the DB
holds — the server never sees plaintext.
- Service worker (sw.js) decrypts the payload locally with the group key (kept in
IndexedDB, since SWs can't read localStorage) and shows the notification;
suppressed when the app is open/focused.
- push.js: registers the SW, runs the subscribe flow, handles the iOS
"Add to Home Screen" requirement.
- PWA: manifest + generated chat-bubble icons + apple-touch-icon.
- API: GET /api/push/vapid, POST /api/push/{subscribe,unsubscribe}; SW served
from root scope. Revoking an invite drops its subscriptions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
15 files changed,
+541
−12
db.go+7 −0go.mod+4 −1go.sum+73 −6hub.go+2 −0main.go+101 −4push.go+114 −0web/pages/index.html+10 −1web/static/chat.js+4 −0web/static/icon-180.pngbinaryweb/static/icon-192.pngbinaryweb/static/icon-512.pngbinaryweb/static/manifest.webmanifest+14 −0web/static/push.js+113 −0web/static/style.css+10 −0web/static/sw.js+89 −0
diff --git a/db.go b/db.go| index 13a0b35..043b270 100644 |
| --- a/db.go |
| +++ b/db.go |
| @@ -58,6 +58,13 @@ CREATE TABLE IF NOT EXISTS messages ( |
| id INTEGER PRIMARY KEY AUTOINCREMENT, |
| created_at INTEGER NOT NULL, |
| blob TEXT NOT NULL |
| +); |
| +CREATE TABLE IF NOT EXISTS push_subscriptions ( |
| + endpoint TEXT PRIMARY KEY, |
| + token TEXT NOT NULL, |
| + p256dh TEXT NOT NULL, |
| + auth TEXT NOT NULL, |
| + created_at INTEGER NOT NULL |
| );`) |
| return err |
| } |
diff --git a/go.mod b/go.mod| index 97094a8..67190ed 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -3,18 +3,21 @@ module lchat |
| go 1.23 |
| require ( |
| + github.com/SherClockHolmes/webpush-go v1.4.0 |
| github.com/coder/websocket v1.8.12 |
| modernc.org/sqlite v1.34.4 |
| ) |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| + github.com/golang-jwt/jwt/v5 v5.2.1 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| github.com/hashicorp/golang-lru/v2 v2.0.7 // 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 |
| - golang.org/x/sys v0.22.0 // indirect |
| + golang.org/x/crypto v0.31.0 // indirect |
| + golang.org/x/sys v0.28.0 // indirect |
| modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect |
| modernc.org/libc v1.55.3 // indirect |
| modernc.org/mathutil v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index 3ad8c28..19381e0 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,12 @@ |
| +github.com/SherClockHolmes/webpush-go v1.4.0 h1:ocnzNKWN23T9nvHi6IfyrQjkIc0oJWv1B1pULsf9i3s= |
| +github.com/SherClockHolmes/webpush-go v1.4.0/go.mod h1:XSq8pKX11vNV8MJEMwjrlTkxhAj1zKfxmyhdV7Pd6UA= |
| github.com/coder/websocket v1.8.12 h1:5bUXkEPPIbewrnkU8LTCLVaxi4N4J8ahufH2vlo4NAo= |
| github.com/coder/websocket v1.8.12/go.mod h1:LNVeNrXQZfe5qhS9ALED3uA+l5pPqvwXg3CKoDBB2gs= |
| 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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= |
| +github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= |
| +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= |
| github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= |
| github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= |
| github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| @@ -16,13 +21,75 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb |
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= |
| -golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= |
| -golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= |
| +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= |
| +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= |
| +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 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/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 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= |
| +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-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/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= |
| +golang.org/x/sync v0.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 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ= |
| +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-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.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= |
| -golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= |
| -golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= |
| -golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= |
| +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 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= |
| +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.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.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/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= |
| +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= |
| +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= |
| +golang.org/x/tools v0.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 h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= |
| +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= |
| modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= |
| modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= |
| modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= |
diff --git a/hub.go b/hub.go| index dc7070c..360b2c4 100644 |
| --- a/hub.go |
| +++ b/hub.go |
| @@ -134,5 +134,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { |
| } |
| out, _ := json.Marshal(wsOut{Type: "msg", ID: m.ID, CreatedAt: m.CreatedAt, Blob: m.Blob}) |
| s.hub.broadcast <- out |
| + // Notify subscribers who don't have the app open (best-effort, async). |
| + go s.pushNewMessage(m.Blob) |
| } |
| } |
diff --git a/main.go b/main.go| index ddb1f49..ce6fc89 100644 |
| --- a/main.go |
| +++ b/main.go |
| @@ -16,9 +16,11 @@ import ( |
| var webFS embed.FS |
| type Server struct { |
| - db *DB |
| - hub *Hub |
| - pages map[string][]byte |
| + db *DB |
| + hub *Hub |
| + pages map[string][]byte |
| + vapidPub string |
| + vapidPriv string |
| } |
| func main() { |
| @@ -57,6 +59,13 @@ func main() { |
| go hub.run() |
| s := &Server{db: db, hub: hub, pages: pages} |
| + // Web Push: self-generated VAPID keypair (no third-party account). |
| + if pub, priv, err := db.ensureVAPID(); err != nil { |
| + log.Fatalf("vapid: %v", err) |
| + } else { |
| + s.vapidPub, s.vapidPriv = pub, priv |
| + } |
| + |
| staticFS, err := fs.Sub(webFS, "web/static") |
| if err != nil { |
| log.Fatalf("static fs: %v", err) |
| @@ -82,6 +91,15 @@ func main() { |
| mux.HandleFunc("GET /api/admin/invites", s.listInvites) |
| mux.HandleFunc("POST /api/admin/invites/{token}/revoke", s.revokeInvite) |
| + // Web Push (VAPID self-generated; pushed payload is ciphertext, decrypted in the SW) |
| + mux.HandleFunc("GET /api/push/vapid", s.getVAPID) |
| + mux.HandleFunc("POST /api/push/subscribe", s.subscribePush) |
| + mux.HandleFunc("POST /api/push/unsubscribe", s.unsubscribePush) |
| + |
| + // PWA: the service worker must be served from the root scope; manifest for install |
| + mux.HandleFunc("GET /sw.js", s.serveSW) |
| + mux.HandleFunc("GET /manifest.webmanifest", s.serveManifest) |
| + |
| log.Printf("lchat listening on %s (db: %s)", *addr, *dbPath) |
| if err := http.ListenAndServe(*addr, mux); err != nil { |
| log.Fatal(err) |
| @@ -206,13 +224,92 @@ func (s *Server) revokeInvite(w http.ResponseWriter, r *http.Request) { |
| if !s.requireAdmin(w, r) { |
| return |
| } |
| - if err := s.db.revokeInvite(r.PathValue("token")); err != nil { |
| + token := r.PathValue("token") |
| + if err := s.db.revokeInvite(token); err != nil { |
| httpError(w, http.StatusInternalServerError, "db error") |
| return |
| } |
| + // A revoked participant should also stop receiving push notifications. |
| + _ = s.db.deleteSubscriptionsForToken(token) |
| writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| } |
| +// ---- push notifications ------------------------------------------------- |
| + |
| +func (s *Server) getVAPID(w http.ResponseWriter, r *http.Request) { |
| + writeJSON(w, http.StatusOK, map[string]any{"publicKey": s.vapidPub}) |
| +} |
| + |
| +func (s *Server) subscribePush(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Token string `json:"token"` |
| + Subscription struct { |
| + Endpoint string `json:"endpoint"` |
| + Keys struct { |
| + P256dh string `json:"p256dh"` |
| + Auth string `json:"auth"` |
| + } `json:"keys"` |
| + } `json:"subscription"` |
| + } |
| + if json.NewDecoder(r.Body).Decode(&body) != nil { |
| + httpError(w, http.StatusBadRequest, "bad request") |
| + return |
| + } |
| + // Only valid (non-revoked) participants may register for notifications. |
| + exists, revoked, err := s.db.inviteStatus(body.Token) |
| + if err != nil || !exists || revoked { |
| + httpError(w, http.StatusForbidden, "invalid invite") |
| + return |
| + } |
| + if body.Subscription.Endpoint == "" { |
| + httpError(w, http.StatusBadRequest, "missing subscription") |
| + return |
| + } |
| + if err := s.db.addSubscription(PushSub{ |
| + Endpoint: body.Subscription.Endpoint, |
| + Token: body.Token, |
| + P256dh: body.Subscription.Keys.P256dh, |
| + Auth: body.Subscription.Keys.Auth, |
| + }); err != nil { |
| + httpError(w, http.StatusInternalServerError, "db error") |
| + return |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +func (s *Server) unsubscribePush(w http.ResponseWriter, r *http.Request) { |
| + var body struct { |
| + Endpoint string `json:"endpoint"` |
| + } |
| + json.NewDecoder(r.Body).Decode(&body) |
| + if body.Endpoint != "" { |
| + _ = s.db.deleteSubscription(body.Endpoint) |
| + } |
| + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) |
| +} |
| + |
| +// serveSW serves the service worker from the root path so its scope covers the app. |
| +func (s *Server) serveSW(w http.ResponseWriter, r *http.Request) { |
| + b, err := webFS.ReadFile("web/static/sw.js") |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/javascript; charset=utf-8") |
| + w.Header().Set("Cache-Control", "no-cache") |
| + w.Write(b) |
| +} |
| + |
| +func (s *Server) serveManifest(w http.ResponseWriter, r *http.Request) { |
| + b, err := webFS.ReadFile("web/static/manifest.webmanifest") |
| + if err != nil { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.Header().Set("Content-Type", "application/manifest+json; charset=utf-8") |
| + w.Write(b) |
| +} |
| + |
| // ---- helpers ------------------------------------------------------------ |
| func writeJSON(w http.ResponseWriter, status int, v any) { |
diff --git a/push.go b/push.go| new file mode 100644 |
| index 0000000..173bf7a |
| --- /dev/null |
| +++ b/push.go |
| @@ -0,0 +1,114 @@ |
| +package main |
| + |
| +import ( |
| + "encoding/json" |
| + "log" |
| + "net/http" |
| + "time" |
| + |
| + webpush "github.com/SherClockHolmes/webpush-go" |
| +) |
| + |
| +// PushSub is a stored Web Push subscription, tied to the invite token that made it. |
| +type PushSub struct { |
| + Endpoint string |
| + Token string |
| + P256dh string |
| + Auth string |
| +} |
| + |
| +func (db *DB) addSubscription(s PushSub) error { |
| + _, err := db.sql.Exec( |
| + `INSERT INTO push_subscriptions(endpoint, token, p256dh, auth, created_at) |
| + VALUES(?, ?, ?, ?, ?) |
| + ON CONFLICT(endpoint) DO UPDATE SET |
| + token=excluded.token, p256dh=excluded.p256dh, auth=excluded.auth`, |
| + s.Endpoint, s.Token, s.P256dh, s.Auth, time.Now().Unix(), |
| + ) |
| + return err |
| +} |
| + |
| +func (db *DB) listSubscriptions() ([]PushSub, error) { |
| + rows, err := db.sql.Query("SELECT endpoint, token, p256dh, auth FROM push_subscriptions") |
| + if err != nil { |
| + return nil, err |
| + } |
| + defer rows.Close() |
| + out := []PushSub{} |
| + for rows.Next() { |
| + var s PushSub |
| + if err := rows.Scan(&s.Endpoint, &s.Token, &s.P256dh, &s.Auth); err != nil { |
| + return nil, err |
| + } |
| + out = append(out, s) |
| + } |
| + return out, rows.Err() |
| +} |
| + |
| +func (db *DB) deleteSubscription(endpoint string) error { |
| + _, err := db.sql.Exec("DELETE FROM push_subscriptions WHERE endpoint = ?", endpoint) |
| + return err |
| +} |
| + |
| +func (db *DB) deleteSubscriptionsForToken(token string) error { |
| + _, err := db.sql.Exec("DELETE FROM push_subscriptions WHERE token = ?", token) |
| + return err |
| +} |
| + |
| +// ensureVAPID returns the server's VAPID keypair, generating and persisting one on |
| +// first use. VAPID lets the server authenticate itself to the browser push services |
| +// with no third-party account — it is unrelated to message encryption. |
| +func (db *DB) ensureVAPID() (pub, priv string, err error) { |
| + pub, okPub, _ := db.getConfig("vapid_public") |
| + priv, okPriv, _ := db.getConfig("vapid_private") |
| + if okPub && okPriv { |
| + return pub, priv, nil |
| + } |
| + priv, pub, err = webpush.GenerateVAPIDKeys() |
| + if err != nil { |
| + return "", "", err |
| + } |
| + if err = db.setConfig("vapid_public", pub); err != nil { |
| + return "", "", err |
| + } |
| + if err = db.setConfig("vapid_private", priv); err != nil { |
| + return "", "", err |
| + } |
| + return pub, priv, nil |
| +} |
| + |
| +// pushNewMessage delivers the ciphertext blob to every subscription. The server |
| +// never sees plaintext: the payload IS the same ciphertext stored in the DB, and |
| +// the service worker decrypts it locally with the group key. Expired subscriptions |
| +// (404/410) are pruned. Best-effort; runs in its own goroutine. |
| +func (s *Server) pushNewMessage(blob string) { |
| + subs, err := s.db.listSubscriptions() |
| + if err != nil { |
| + log.Println("push: list subs:", err) |
| + return |
| + } |
| + if len(subs) == 0 { |
| + return |
| + } |
| + payload, _ := json.Marshal(map[string]string{"blob": blob}) |
| + for _, sub := range subs { |
| + resp, err := webpush.SendNotification(payload, &webpush.Subscription{ |
| + Endpoint: sub.Endpoint, |
| + Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}, |
| + }, &webpush.Options{ |
| + Subscriber: "mailto:admin@luan.chat", |
| + VAPIDPublicKey: s.vapidPub, |
| + VAPIDPrivateKey: s.vapidPriv, |
| + TTL: 60, |
| + Urgency: webpush.UrgencyHigh, |
| + }) |
| + if err != nil { |
| + log.Println("push: send:", err) |
| + continue |
| + } |
| + resp.Body.Close() |
| + if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone { |
| + _ = s.db.deleteSubscription(sub.Endpoint) |
| + } |
| + } |
| +} |
diff --git a/web/pages/index.html b/web/pages/index.html| index 2fb1ec0..fb43cfe 100644 |
| --- a/web/pages/index.html |
| +++ b/web/pages/index.html |
| @@ -1,7 +1,13 @@ |
| <meta charset="utf-8" /> |
| <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" /> |
| +<meta name="theme-color" content="#5b6cff" /> |
| <title>lchat</title> |
| <link rel="stylesheet" href="/static/style.css" /> |
| +<link rel="manifest" href="/manifest.webmanifest" /> |
| +<link rel="icon" href="/static/icon-192.png" /> |
| +<link rel="apple-touch-icon" href="/static/icon-180.png" /> |
| +<meta name="apple-mobile-web-app-capable" content="yes" /> |
| +<meta name="apple-mobile-web-app-title" content="lchat" /> |
| <div id="app"></div> |
| <div id="toast" class="toast"></div> |
| @@ -13,7 +19,10 @@ |
| <div class="title">Chat</div> |
| <div class="who"></div> |
| </div> |
| - <button class="secondary" id="leave">Leave</button> |
| + <div class="header-actions"> |
| + <button class="notif hidden" id="notif"></button> |
| + <button class="secondary" id="leave">Leave</button> |
| + </div> |
| </header> |
| <div class="banner hidden" id="banner"></div> |
| <div id="feed"></div> |
diff --git a/web/static/chat.js b/web/static/chat.js| index fd564f7..7100bc4 100644 |
| --- a/web/static/chat.js |
| +++ b/web/static/chat.js |
| @@ -1,4 +1,5 @@ |
| import { importKey, encrypt, decrypt } from "/static/crypto.js"; |
| +import { initPush } from "/static/push.js"; |
| const SESSION_KEY = "lchat_session"; |
| @@ -34,6 +35,9 @@ async function start(session) { |
| const who = document.querySelector(".who"); |
| who.textContent = `You are ${session.name}`; |
| + // Web Push setup (service worker + subscribe button). Best-effort. |
| + initPush(session, document.getElementById("notif")).catch((e) => console.warn("push init", e)); |
| + |
| document.getElementById("leave").onclick = () => { |
| if (confirm("Leave this chat on this device? You'll need your link to rejoin.")) { |
| localStorage.removeItem(SESSION_KEY); |
diff --git a/web/static/icon-180.png b/web/static/icon-180.png| new file mode 100644 |
| index 0000000..aa6ca55 |
| Binary files /dev/null and b/web/static/icon-180.png differ |
diff --git a/web/static/icon-192.png b/web/static/icon-192.png| new file mode 100644 |
| index 0000000..7f017fd |
| Binary files /dev/null and b/web/static/icon-192.png differ |
diff --git a/web/static/icon-512.png b/web/static/icon-512.png| new file mode 100644 |
| index 0000000..495c609 |
| Binary files /dev/null and b/web/static/icon-512.png differ |
diff --git a/web/static/manifest.webmanifest b/web/static/manifest.webmanifest| new file mode 100644 |
| index 0000000..d602680 |
| --- /dev/null |
| +++ b/web/static/manifest.webmanifest |
| @@ -0,0 +1,14 @@ |
| +{ |
| + "name": "lchat", |
| + "short_name": "lchat", |
| + "description": "A private, end-to-end encrypted group chat", |
| + "start_url": "/", |
| + "scope": "/", |
| + "display": "standalone", |
| + "background_color": "#f6f7fb", |
| + "theme_color": "#5b6cff", |
| + "icons": [ |
| + { "src": "/static/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, |
| + { "src": "/static/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" } |
| + ] |
| +} |
diff --git a/web/static/push.js b/web/static/push.js| new file mode 100644 |
| index 0000000..35cd395 |
| --- /dev/null |
| +++ b/web/static/push.js |
| @@ -0,0 +1,113 @@ |
| +// Client-side Web Push: registers the service worker, stashes the group key in |
| +// IndexedDB (so the SW can decrypt push payloads), and runs the subscribe flow. |
| + |
| +function idbSet(key, value) { |
| + return new Promise((resolve, reject) => { |
| + const req = indexedDB.open("lchat", 1); |
| + req.onupgradeneeded = () => req.result.createObjectStore("kv"); |
| + req.onsuccess = () => { |
| + const tx = req.result.transaction("kv", "readwrite"); |
| + tx.objectStore("kv").put(value, key); |
| + tx.oncomplete = () => resolve(); |
| + tx.onerror = () => reject(tx.error); |
| + }; |
| + req.onerror = () => reject(req.error); |
| + }); |
| +} |
| + |
| +function urlB64ToUint8Array(b64) { |
| + const pad = "=".repeat((4 - (b64.length % 4)) % 4); |
| + const base64 = (b64 + pad).replace(/-/g, "+").replace(/_/g, "/"); |
| + const raw = atob(base64); |
| + const out = new Uint8Array(raw.length); |
| + for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i); |
| + return out; |
| +} |
| + |
| +const isiOS = /iphone|ipad|ipod/i.test(navigator.userAgent); |
| +const isStandalone = |
| + window.matchMedia("(display-mode: standalone)").matches || |
| + window.navigator.standalone === true; |
| + |
| +export async function initPush(session, btn) { |
| + if (!("serviceWorker" in navigator) || !("PushManager" in window)) return; |
| + |
| + // The service worker decrypts push payloads with this. |
| + await idbSet("groupKey", session.key); |
| + |
| + let reg; |
| + try { |
| + reg = await navigator.serviceWorker.register("/sw.js"); |
| + } catch (e) { |
| + console.warn("service worker registration failed", e); |
| + return; |
| + } |
| + |
| + // iOS only delivers web push to installed (home-screen) PWAs. |
| + if (isiOS && !isStandalone) { |
| + setStatus(btn, "ios"); |
| + return; |
| + } |
| + |
| + btn.onclick = async () => { |
| + if (Notification.permission === "default") { |
| + const perm = await Notification.requestPermission(); |
| + if (perm !== "granted") return setStatus(btn, "denied"); |
| + } |
| + if (Notification.permission === "granted") await subscribe(reg, session, btn); |
| + else setStatus(btn, "denied"); |
| + }; |
| + |
| + const existing = await reg.pushManager.getSubscription(); |
| + if (existing && Notification.permission === "granted") { |
| + await sendSubscription(session.token, existing); // keep server in sync |
| + setStatus(btn, "on"); |
| + } else if (Notification.permission === "granted") { |
| + await subscribe(reg, session, btn); |
| + } else if (Notification.permission === "denied") { |
| + setStatus(btn, "denied"); |
| + } else { |
| + setStatus(btn, "ask"); |
| + } |
| +} |
| + |
| +async function subscribe(reg, session, btn) { |
| + try { |
| + const { publicKey } = await (await fetch("/api/push/vapid")).json(); |
| + const sub = await reg.pushManager.subscribe({ |
| + userVisibleOnly: true, |
| + applicationServerKey: urlB64ToUint8Array(publicKey), |
| + }); |
| + await sendSubscription(session.token, sub); |
| + setStatus(btn, "on"); |
| + } catch (e) { |
| + console.warn("push subscribe failed", e); |
| + setStatus(btn, "ask"); |
| + } |
| +} |
| + |
| +function sendSubscription(token, sub) { |
| + return fetch("/api/push/subscribe", { |
| + method: "POST", |
| + headers: { "Content-Type": "application/json" }, |
| + body: JSON.stringify({ token, subscription: sub }), |
| + }); |
| +} |
| + |
| +function setStatus(btn, state) { |
| + if (!btn) return; |
| + btn.classList.remove("hidden"); |
| + const text = { |
| + on: "🔔 On", |
| + ask: "🔔 Notify me", |
| + denied: "🔕 Blocked", |
| + ios: "🔔 Add to Home Screen", |
| + }; |
| + btn.textContent = text[state] || ""; |
| + btn.title = |
| + state === "denied" |
| + ? "Notifications are blocked — turn them on in your browser settings" |
| + : state === "ios" |
| + ? "On iPhone/iPad, add this page to your Home Screen, then open it to enable notifications" |
| + : ""; |
| +} |
diff --git a/web/static/style.css b/web/static/style.css| index 6407f42..61b1e98 100644 |
| --- a/web/static/style.css |
| +++ b/web/static/style.css |
| @@ -170,3 +170,13 @@ input:focus, textarea:focus { outline: 2px solid var(--accent); border-color: tr |
| font-size: 14px; opacity: 0; transition: opacity .2s; pointer-events: none; |
| } |
| .toast.show { opacity: 1; } |
| + |
| +.header-actions { display: flex; gap: 8px; align-items: center; } |
| +.notif { |
| + background: var(--bubble-them); |
| + color: var(--ink); |
| + font-size: 13px; |
| + font-weight: 600; |
| + padding: 7px 12px; |
| + white-space: nowrap; |
| +} |
diff --git a/web/static/sw.js b/web/static/sw.js| new file mode 100644 |
| index 0000000..2559f06 |
| --- /dev/null |
| +++ b/web/static/sw.js |
| @@ -0,0 +1,89 @@ |
| +// lchat service worker — handles Web Push. |
| +// |
| +// The push payload is the SAME ciphertext the server stores; it never sees |
| +// plaintext. We decrypt here, in the browser, with the group key kept in |
| +// IndexedDB (service workers can't read localStorage), so notifications can show |
| +// real content while staying end-to-end encrypted. |
| + |
| +self.addEventListener("install", () => self.skipWaiting()); |
| +self.addEventListener("activate", (e) => e.waitUntil(self.clients.claim())); |
| + |
| +function b64urlToBytes(s) { |
| + s = s.replace(/-/g, "+").replace(/_/g, "/"); |
| + while (s.length % 4) s += "="; |
| + const bin = atob(s); |
| + const out = new Uint8Array(bin.length); |
| + for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); |
| + return out; |
| +} |
| + |
| +// Read a value from the "lchat" IndexedDB (shared with the page; see push.js). |
| +function idbGet(key) { |
| + return new Promise((resolve, reject) => { |
| + const req = indexedDB.open("lchat", 1); |
| + req.onupgradeneeded = () => req.result.createObjectStore("kv"); |
| + req.onsuccess = () => { |
| + const tx = req.result.transaction("kv", "readonly"); |
| + const g = tx.objectStore("kv").get(key); |
| + g.onsuccess = () => resolve(g.result); |
| + g.onerror = () => reject(g.error); |
| + }; |
| + req.onerror = () => reject(req.error); |
| + }); |
| +} |
| + |
| +async function decryptBlob(blob, keyB64) { |
| + const key = await crypto.subtle.importKey( |
| + "raw", b64urlToBytes(keyB64), "AES-GCM", false, ["decrypt"] |
| + ); |
| + const data = b64urlToBytes(blob); |
| + const pt = await crypto.subtle.decrypt( |
| + { name: "AES-GCM", iv: data.slice(0, 12) }, key, data.slice(12) |
| + ); |
| + return JSON.parse(new TextDecoder().decode(pt)); |
| +} |
| + |
| +self.addEventListener("push", (event) => { |
| + event.waitUntil(handlePush(event)); |
| +}); |
| + |
| +async function handlePush(event) { |
| + // Don't notify if the chat is already open and focused — they'll see it live. |
| + const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| + if (wins.some((c) => c.visibilityState === "visible" && c.focused)) return; |
| + |
| + let title = "New message"; |
| + let body = "Open the chat to read it"; |
| + try { |
| + const data = event.data ? event.data.json() : {}; |
| + const keyB64 = await idbGet("groupKey"); |
| + if (data.blob && keyB64) { |
| + const msg = await decryptBlob(data.blob, keyB64); |
| + title = msg.name || "New message"; |
| + body = msg.text || ""; |
| + } |
| + } catch (e) { |
| + // Fall back to the generic notification above. |
| + } |
| + |
| + await self.registration.showNotification(title, { |
| + body, |
| + icon: "/static/icon-192.png", |
| + badge: "/static/icon-192.png", |
| + tag: "lchat-message", |
| + renotify: true, |
| + }); |
| +} |
| + |
| +self.addEventListener("notificationclick", (event) => { |
| + event.notification.close(); |
| + event.waitUntil( |
| + (async () => { |
| + const wins = await self.clients.matchAll({ type: "window", includeUncontrolled: true }); |
| + for (const c of wins) { |
| + if ("focus" in c) return c.focus(); |
| + } |
| + if (self.clients.openWindow) return self.clients.openWindow("/"); |
| + })() |
| + ); |
| +}); |