diff --git a/pkg/config/enum.go b/pkg/config/enum.go index 2fa7c4b..63b33bd 100644 --- a/pkg/config/enum.go +++ b/pkg/config/enum.go @@ -142,6 +142,10 @@ const ( NotificationOptOutSubscriptions = "notif_optout_subscriptions" NotificationOptOutFriendRequestAccepted = "notif_optout_friend_request_accepted" NotificationOptOutPrivateGrant = "notif_optout_private_grant" + + // Web Push Notifications + PushNotificationOptOutMessage = "notif_optout_push_messages" + PushNotificationOptOutFriends = "notif_optout_push_friends" ) // Notification opt-outs (stored in ProfileField table) @@ -155,3 +159,9 @@ var NotificationOptOutFields = []string{ NotificationOptOutFriendRequestAccepted, NotificationOptOutPrivateGrant, } + +// Push Notification opt-outs (stored in ProfileField table) +var PushNotificationOptOutFields = []string{ + PushNotificationOptOutMessage, + PushNotificationOptOutFriends, +} diff --git a/pkg/config/variable.go b/pkg/config/variable.go index 576c8cd..3edad1b 100644 --- a/pkg/config/variable.go +++ b/pkg/config/variable.go @@ -9,6 +9,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/encryption/coldstorage" "code.nonshy.com/nonshy/website/pkg/encryption/keygen" "code.nonshy.com/nonshy/website/pkg/log" + "github.com/SherClockHolmes/webpush-go" "github.com/google/uuid" ) @@ -31,6 +32,7 @@ type Variable struct { BareRTC BareRTC Maintenance Maintenance Encryption Encryption + WebPush WebPush Turnstile Turnstile UseXForwardedFor bool } @@ -111,6 +113,19 @@ func LoadSettings() { writeSettings = true } + // Initialize the VAPID keys for Web Push Notification. + if len(Current.WebPush.VAPIDPublicKey) == 0 { + privateKey, publicKey, err := webpush.GenerateVAPIDKeys() + if err != nil { + log.Error("Initializing VAPID keys for Web Push: %s", err) + os.Exit(1) + } + + Current.WebPush.VAPIDPrivateKey = privateKey + Current.WebPush.VAPIDPublicKey = publicKey + writeSettings = true + } + // Have we added new config fields? Save the settings.json. if Current.Version != currentVersion || writeSettings { log.Warn("New options are available for your settings.json file. Your settings will be re-saved now.") @@ -181,6 +196,12 @@ type Encryption struct { ColdStorageRSAPublicKey []byte } +// WebPush settings. +type WebPush struct { + VAPIDPublicKey string + VAPIDPrivateKey string +} + // Turnstile (Cloudflare CAPTCHA) settings. type Turnstile struct { Enabled bool diff --git a/pkg/controller/account/settings.go b/pkg/controller/account/settings.go index 187a375..2784bc5 100644 --- a/pkg/controller/account/settings.go +++ b/pkg/controller/account/settings.go @@ -276,6 +276,28 @@ func Settings() http.HandlerFunc { session.Flash(w, r, "Unsubscribed from all comment threads!") } } + case "push_notifications": + hashtag = "#notifications" + + // Store their notification opt-outs. + for _, key := range config.PushNotificationOptOutFields { + var value = r.PostFormValue(key) + + if value == "" { + value = "true" // opt-out, store opt-out=true in the DB + } else if value == "true" { + value = "false" // the box remained checked, they don't opt-out, store opt-out=false in the DB + } + + // Save it. + user.SetProfileField(key, value) + } + session.Flash(w, r, "Notification preferences updated!") + + // Save the user for new fields to be committed to DB. + if err := user.Save(); err != nil { + session.FlashError(w, r, "Failed to save user to database: %s", err) + } case "location": hashtag = "#location" var ( @@ -472,6 +494,9 @@ func Settings() http.HandlerFunc { // Count of subscribed comment threads. vars["SubscriptionCount"] = models.CountSubscriptions(user) + // Count of push notification subscriptions. + vars["PushNotificationsCount"] = models.CountPushNotificationSubscriptions(user) + if err := tmpl.Execute(w, r, vars); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return diff --git a/pkg/controller/friend/request.go b/pkg/controller/friend/request.go index c52a667..c4ba8f4 100644 --- a/pkg/controller/friend/request.go +++ b/pkg/controller/friend/request.go @@ -10,6 +10,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" + "code.nonshy.com/nonshy/website/pkg/webpush" ) // AddFriend controller to send a friend request. @@ -122,6 +123,21 @@ func AddFriend() http.HandlerFunc { } else { // Log the change. models.LogCreated(currentUser, "friends", user.ID, "Sent a friend request to "+user.Username+".") + + // Send a push notification to the recipient. + go func() { + // Opted out of this one? + if user.GetProfileField(config.PushNotificationOptOutFriends) == "true" { + return + } + + log.Info("Try and send Web Push notification about new Friend Request to: %s", user.Username) + webpush.SendNotification(user, webpush.Payload{ + Topic: "friend", + Title: "New Friend Request!", + Body: fmt.Sprintf("%s wants to be your friend on %s.", currentUser.Username, config.Title), + }) + }() } session.Flash(w, r, "Friend request sent!") } diff --git a/pkg/controller/inbox/compose.go b/pkg/controller/inbox/compose.go index 94e6ce4..ae58f80 100644 --- a/pkg/controller/inbox/compose.go +++ b/pkg/controller/inbox/compose.go @@ -4,9 +4,12 @@ import ( "fmt" "net/http" + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/log" "code.nonshy.com/nonshy/website/pkg/models" "code.nonshy.com/nonshy/website/pkg/session" "code.nonshy.com/nonshy/website/pkg/templates" + "code.nonshy.com/nonshy/website/pkg/webpush" ) // Compose a new chat coming from a user's profile page. @@ -61,9 +64,25 @@ func Compose() http.HandlerFunc { return } + // Send a push notification to the recipient. + go func() { + // Opted out of this one? + if user.GetProfileField(config.PushNotificationOptOutMessage) == "true" { + return + } + + log.Info("Try and send Web Push notification about new Message to: %s", user.Username) + webpush.SendNotification(user, webpush.Payload{ + Topic: "inbox", + Title: "New Message!", + Body: fmt.Sprintf("%s has left you a message on %s.", currentUser.Username, config.Title), + }) + }() + session.Flash(w, r, "Your message has been delivered!") if from == "inbox" { templates.Redirect(w, fmt.Sprintf("/messages/read/%d", m.ID)) + return } templates.Redirect(w, "/messages") return diff --git a/pkg/controller/index/index.go b/pkg/controller/index/index.go index a57b101..648eece 100644 --- a/pkg/controller/index/index.go +++ b/pkg/controller/index/index.go @@ -38,3 +38,12 @@ func Manifest() http.HandlerFunc { http.ServeFile(w, r, config.StaticPath+"/manifest.json") }) } + +// Service Worker for web push. +func ServiceWorker() http.HandlerFunc { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/javascript; charset=UTF-8") + w.Header().Add("Service-Worker-Allowed", "/") + http.ServeFile(w, r, config.StaticPath+"/js/service-worker.js") + }) +} diff --git a/pkg/models/deletion/delete_user.go b/pkg/models/deletion/delete_user.go index 771ea83..17f9451 100644 --- a/pkg/models/deletion/delete_user.go +++ b/pkg/models/deletion/delete_user.go @@ -55,6 +55,7 @@ func DeleteUser(user *models.User) error { {"User Notes", DeleteUserNotes}, {"Change Logs", DeleteChangeLogs}, {"IP Addresses", DeleteIPAddresses}, + {"Push Notifications", DeletePushNotifications}, } for _, item := range todo { if err := item.Fn(user.ID); err != nil { @@ -361,3 +362,13 @@ func DeleteIPAddresses(userID uint64) error { ).Delete(&models.IPAddress{}) return result.Error } + +// DeletePushNotifications scrubs data for deleting a user. +func DeletePushNotifications(userID uint64) error { + log.Error("DeleteUser: DeletePushNotifications(%d)", userID) + result := models.DB.Where( + "user_id = ?", + userID, + ).Delete(&models.PushNotification{}) + return result.Error +} diff --git a/pkg/models/models.go b/pkg/models/models.go index 6647cc1..f04780e 100644 --- a/pkg/models/models.go +++ b/pkg/models/models.go @@ -33,4 +33,5 @@ func AutoMigrate() { DB.AutoMigrate(&TwoFactor{}) DB.AutoMigrate(&ChangeLog{}) DB.AutoMigrate(&IPAddress{}) + DB.AutoMigrate(&PushNotification{}) } diff --git a/pkg/models/notification_push.go b/pkg/models/notification_push.go new file mode 100644 index 0000000..421f718 --- /dev/null +++ b/pkg/models/notification_push.go @@ -0,0 +1,85 @@ +package models + +import ( + "time" + + "code.nonshy.com/nonshy/website/pkg/log" +) + +// PushNotification table for Web Push subscriptions. +type PushNotification struct { + ID uint64 `gorm:"primaryKey"` + UserID uint64 `gorm:"index"` + Subscription string `gorm:"index"` + CreatedAt time.Time + UpdatedAt time.Time +} + +// RegisterPushNotification stores a registration for the user. +func RegisterPushNotification(user *User, subscription string) (*PushNotification, error) { + // Check for an existing registration. + pn, err := GetPushNotificationFromSubscription(user, subscription) + if err == nil { + return pn, nil + } + + // Create it. + pn = &PushNotification{ + UserID: user.ID, + Subscription: subscription, + } + result := DB.Create(pn) + return pn, result.Error +} + +// GetPushNotificationFromSubscription checks for an existing subscription. +func GetPushNotificationFromSubscription(user *User, subscription string) (*PushNotification, error) { + var ( + pn *PushNotification + result = DB.Model(&PushNotification{}).Where( + "user_id = ? AND subscription = ?", + user.ID, subscription, + ).First(&pn) + ) + return pn, result.Error +} + +// CountPushNotificationSubscriptions returns how many subscriptions the user has for push. +func CountPushNotificationSubscriptions(user *User) int64 { + var count int64 + result := DB.Where( + "user_id = ?", + user.ID, + ).Model(&PushNotification{}).Count(&count) + if result.Error != nil { + log.Error("CountPushNotificationSubscriptions(%d): %s", user.ID, result.Error) + } + return count +} + +// GetPushNotificationSubscriptions returns all subscriptions for a user. +func GetPushNotificationSubscriptions(user *User) ([]*PushNotification, error) { + var ( + pn = []*PushNotification{} + result = DB.Model(&PushNotification{}).Where("user_id = ?", user.ID).Scan(&pn) + ) + return pn, result.Error +} + +// DeletePushNotifications scrubs data for deleting a user. +func DeletePushNotificationSubscriptions(user *User) error { + result := DB.Where( + "user_id = ?", + user.ID, + ).Delete(&PushNotification{}) + return result.Error +} + +// DeletePushNotification removes a single subscription from the database. +func DeletePushNotification(user *User, subscription string) error { + result := DB.Where( + "user_id = ? AND subscription = ?", + user.ID, subscription, + ).Delete(&PushNotification{}) + return result.Error +} diff --git a/pkg/router/router.go b/pkg/router/router.go index a5bb6ba..24b82fd 100644 --- a/pkg/router/router.go +++ b/pkg/router/router.go @@ -21,6 +21,7 @@ import ( "code.nonshy.com/nonshy/website/pkg/controller/poll" "code.nonshy.com/nonshy/website/pkg/middleware" nst "code.nonshy.com/nonshy/website/pkg/templates" + "code.nonshy.com/nonshy/website/pkg/webpush" ) func New() http.Handler { @@ -30,6 +31,7 @@ func New() http.Handler { mux.HandleFunc("/", index.Create()) mux.HandleFunc("GET /favicon.ico", index.Favicon()) mux.HandleFunc("GET /manifest.json", index.Manifest()) + mux.HandleFunc("GET /sw.js", index.ServiceWorker()) mux.HandleFunc("GET /about", index.StaticTemplate("about.html")()) mux.HandleFunc("GET /features", index.StaticTemplate("features.html")()) mux.HandleFunc("GET /faq", index.StaticTemplate("faq.html")()) @@ -111,6 +113,9 @@ func New() http.Handler { mux.HandleFunc("GET /v1/version", api.Version()) mux.HandleFunc("GET /v1/users/me", api.LoginOK()) mux.HandleFunc("POST /v1/users/check-username", api.UsernameCheck()) + mux.HandleFunc("GET /v1/web-push/vapid-public-key", webpush.VAPIDPublicKey) + mux.Handle("POST /v1/web-push/register", middleware.LoginRequired(webpush.Register())) + mux.Handle("GET /v1/web-push/unregister", middleware.LoginRequired(webpush.UnregisterAll())) mux.Handle("POST /v1/likes", middleware.LoginRequired(api.Likes())) mux.Handle("GET /v1/likes/users", middleware.LoginRequired(api.WhoLikes())) mux.Handle("POST /v1/notifications/read", middleware.LoginRequired(api.ReadNotification())) diff --git a/pkg/webpush/webpush.go b/pkg/webpush/webpush.go new file mode 100644 index 0000000..2e1672d --- /dev/null +++ b/pkg/webpush/webpush.go @@ -0,0 +1,180 @@ +// Package webpush provides Web Push Notification functionality. +package webpush + +import ( + "encoding/json" + "fmt" + "net/http" + + "code.nonshy.com/nonshy/website/pkg/config" + "code.nonshy.com/nonshy/website/pkg/controller/api" + "code.nonshy.com/nonshy/website/pkg/log" + "code.nonshy.com/nonshy/website/pkg/models" + "code.nonshy.com/nonshy/website/pkg/session" + "code.nonshy.com/nonshy/website/pkg/templates" + webpush "github.com/SherClockHolmes/webpush-go" +) + +// VAPIDPublicKey returns the site's public key as an endpoint. +func VAPIDPublicKey(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + w.Write([]byte(config.Current.WebPush.VAPIDPublicKey)) +} + +// UnregisterAll resets a user's stored push notification subscriptions. +func UnregisterAll() http.HandlerFunc { + var next = "/settings#notifications" + + return func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + session.FlashError(w, r, "You must be logged in to do that!") + templates.Redirect(w, next) + return + } + + if err := models.DeletePushNotificationSubscriptions(currentUser); err != nil { + session.FlashError(w, r, "Error removing your subscriptions: %s", err) + } else { + session.Flash(w, r, "Your push notification subscriptions have been reset!") + } + templates.Redirect(w, next) + } +} + +// Register endpoint for push notification. +func Register() http.HandlerFunc { + type Request struct { + Endpoint string `json:"endpoint"` + ExpirationTime float64 `json:"expirationTime"` + Keys struct { + Auth string `json:"auth"` + P256DH string `json:"p256dh"` + } `json:"keys"` + } + + type Response struct { + OK bool `json:"OK"` + Error string `json:"error,omitempty"` + } + + return func(w http.ResponseWriter, r *http.Request) { + currentUser, err := session.CurrentUser(r) + if err != nil { + api.SendJSON(w, http.StatusUnauthorized, Response{ + Error: "You must be logged in to do that!", + }) + return + } + + // Parse request payload. + var req Request + if err := api.ParseJSON(r, &req); err != nil { + api.SendJSON(w, http.StatusBadRequest, Response{ + Error: fmt.Sprintf("Error with request payload: %s", err), + }) + return + } + + // Validate it looked correct. + if req.Endpoint == "" || req.Keys.Auth == "" || req.Keys.P256DH == "" { + api.SendJSON(w, http.StatusBadRequest, Response{ + Error: "Subscription fields were missing.", + }) + return + } + + // Serialize and store it in the database. + buf, err := json.Marshal(req) + if err != nil { + api.SendJSON(w, http.StatusInternalServerError, Response{ + Error: "Couldn't reserialize your subscription!", + }) + return + } + + _, err = models.RegisterPushNotification(currentUser, string(buf)) + if err != nil { + api.SendJSON(w, http.StatusInternalServerError, Response{ + Error: "Couldn't create the registration in the database!", + }) + return + } + + api.SendJSON(w, http.StatusCreated, Response{ + OK: true, + }) + } +} + +// Payload sent in push notifications. +type Payload struct { + Topic string `json:"-"` + Title string `json:"title"` + Body string `json:"body"` +} + +// SendNotification sends a push notification to a user, broadcast to all of their subscriptions. +func SendNotification(user *models.User, body Payload) error { + // Send to all of their subscriptions. + subs, err := models.GetPushNotificationSubscriptions(user) + if err != nil { + return err + } + + payload, err := json.Marshal(body) + if err != nil { + return err + } + + for _, sub := range subs { + if err := SendRawNotification(user, payload, body.Topic, sub.Subscription); err != nil { + log.Error("SendNotification: %s", err) + } + } + + return nil +} + +// SendNotificationToSubscription sends to a specific push subscriber. +func SendNotificationToSubscription(user *models.User, subscription string, body Payload) error { + payload, err := json.Marshal(body) + if err != nil { + return err + } + + return SendRawNotification(user, payload, body.Topic, subscription) +} + +// SendRawNotification sends out a push message. +func SendRawNotification(user *models.User, message []byte, topic, subscription string) error { + // Decode the subscription. + var ( + s = &webpush.Subscription{} + err = json.Unmarshal([]byte(subscription), s) + options = &webpush.Options{ + Topic: topic, + Subscriber: user.Email, + VAPIDPublicKey: config.Current.WebPush.VAPIDPublicKey, + VAPIDPrivateKey: config.Current.WebPush.VAPIDPrivateKey, + TTL: 30, + } + ) + if err != nil { + return err + } + + resp, err := webpush.SendNotification(message, s, options) + if err != nil { + return fmt.Errorf("webpush.SendNotification: %s", err) + } + defer resp.Body.Close() + + // Handle error response codes. + if resp.StatusCode >= 400 { + log.Error("Got StatusCode %d when sending push notification; removing the subscription from DB", resp.StatusCode) + models.DeletePushNotification(user, subscription) + } + + return err +} diff --git a/web/static/img/favicon-128.png b/web/static/img/favicon-128.png new file mode 100644 index 0000000..3baf6b9 Binary files /dev/null and b/web/static/img/favicon-128.png differ diff --git a/web/static/img/site-settings.png b/web/static/img/site-settings.png new file mode 100644 index 0000000..7e19864 Binary files /dev/null and b/web/static/img/site-settings.png differ diff --git a/web/static/js/service-worker.js b/web/static/js/service-worker.js new file mode 100644 index 0000000..6a28e44 --- /dev/null +++ b/web/static/js/service-worker.js @@ -0,0 +1,19 @@ +/* nonshy service worker, for web push notifications */ + +self.addEventListener('install', (event) => { + self.skipWaiting(); +}); + +self.addEventListener('push', (event) => { + const payload = JSON.parse(event.data.text()); + try { + event.waitUntil( + self.registration.showNotification(payload.title, { + body: payload.body, + icon: "/static/img/favicon-192.png", + }) + ); + } catch(e) { + console.error("sw.showNotification:", e); + } +}); diff --git a/web/static/js/web-push.js b/web/static/js/web-push.js new file mode 100644 index 0000000..616c7ce --- /dev/null +++ b/web/static/js/web-push.js @@ -0,0 +1,45 @@ +/* nonshy web push notification helper */ +navigator.serviceWorker.register("/sw.js", { + scope: "/", +}).catch(err => { + console.error("Service Worker NOT registered:", err); +}); + +function PushNotificationSubscribe() { + navigator.serviceWorker.ready.then(async function(registration) { + return registration.pushManager.getSubscription().then(async function(subscription) { + // If a subscription was already found, return it. + if (subscription) { + return subscription; + } + + // Get the server's public key. + const response = await fetch("/v1/web-push/vapid-public-key"); + const vapidPublicKey = await response.text(); + + // Subscribe the user. + return registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidPublicKey, + }); + }).then(subscription => { + + // Post it to the backend. + const serialized = JSON.stringify(subscription); + fetch("/v1/web-push/register", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: serialized + }); + }); + }); +} + +// If the user has already given notification permission, (re)subscribe for push. +document.addEventListener("DOMContentLoaded", e => { + if (Notification.permission === "granted") { + PushNotificationSubscribe(); + } +}); diff --git a/web/templates/account/settings.html b/web/templates/account/settings.html index ba7d67c..6818ef5 100644 --- a/web/templates/account/settings.html +++ b/web/templates/account/settings.html @@ -930,188 +930,279 @@ -
- - Notification Settings -
-- On this page you may opt-out of certain kinds of (on-site) notification messages. - {{PrettyTitle}} does not send you any e-mails or push notification -- these on-site - notifications only appear while you are visiting the website (on your - home/user dashboard page). -
- -