eleven / messenger Public

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>
Paul Campbell afce6289b7f764aa19fd599b92aeeefea4a634fe parent aaf55bc
15 files changed, +541 −12
  • db.go +7 −0
  • go.mod +4 −1
  • go.sum +73 −6
  • hub.go +2 −0
  • main.go +101 −4
  • push.go +114 −0
  • web/pages/index.html +10 −1
  • web/static/chat.js +4 −0
  • web/static/icon-180.png binary
  • web/static/icon-192.png binary
  • web/static/icon-512.png binary
  • web/static/manifest.webmanifest +14 −0
  • web/static/push.js +113 −0
  • web/static/style.css +10 −0
  • web/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("/");
+ })()
+ );
+});