Files
dumpsterChat/internal/push/handlers.go
T
hobokenchicken a593f04d7f feat: auto-subscribe push on login, better push logging
- Auto-subscribe to push on login, register, and session restore
- Guard against nil db in SendChannelNotification
- Log push send count per channel message
- Bump SW cache to v5
2026-07-02 14:34:02 -04:00

265 lines
7.3 KiB
Go

package push
import (
"context"
"database/sql"
"encoding/json"
"log/slog"
"net/http"
"github.com/go-chi/chi/v5"
"git.dustin.coffee/hobokenchicken/dumpsterChat/internal/middleware"
webpush "github.com/SherClockHolmes/webpush-go"
)
type Handler struct {
db *sql.DB
vapidPub string
vapidPriv string
vapidSubj string
logger *slog.Logger
}
func NewHandler(db *sql.DB, vapidPub, vapidPriv, vapidSubj string, logger *slog.Logger) *Handler {
return &Handler{
db: db,
vapidPub: vapidPub,
vapidPriv: vapidPriv,
vapidSubj: vapidSubj,
logger: logger,
}
}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Post("/push/subscribe", h.Subscribe)
r.Post("/push/unsubscribe", h.Unsubscribe)
r.Get("/push/vapid-public-key", h.GetPublicKey)
}
// @Summary Get VAPID public key
// @Description Get the VAPID public key for push notification subscriptions
// @Tags push
// @Produce json
// @Success 200 {object} map[string]string
// @Router /push/vapid-public-key [get]
func (h *Handler) GetPublicKey(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"publicKey": h.vapidPub})
}
// @Summary Subscribe to push notifications
// @Description Subscribe to push notifications
// @Tags push
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param body body object true "Push subscription with endpoint, p256dh, and auth"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /push/subscribe [post]
func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req struct {
Endpoint string `json:"endpoint"`
P256DH string `json:"p256dh"`
Auth string `json:"auth"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(),
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id, endpoint) DO UPDATE SET p256dh = $3, auth = $4`,
userID, req.Endpoint, req.P256DH, req.Auth,
)
if err != nil {
h.logger.Error("failed to save push subscription", "error", err)
http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "subscribed"})
}
// @Summary Unsubscribe from push notifications
// @Description Unsubscribe from push notifications
// @Tags push
// @Accept json
// @Produce json
// @Security SessionAuth
// @Param body body object true "Endpoint to unsubscribe"
// @Success 200 {object} map[string]string
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /push/unsubscribe [post]
func (h *Handler) Unsubscribe(w http.ResponseWriter, r *http.Request) {
userID, ok := middleware.UserIDFromContext(r.Context())
if !ok {
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
return
}
var req struct {
Endpoint string `json:"endpoint"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, `{"error":"invalid body"}`, http.StatusBadRequest)
return
}
_, err := h.db.ExecContext(r.Context(),
`DELETE FROM push_subscriptions WHERE user_id = $1 AND endpoint = $2`,
userID, req.Endpoint,
)
if err != nil {
h.logger.Error("failed to delete push subscription", "error", err)
http.Error(w, `{"error":"failed"}`, http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]string{"status": "unsubscribed"})
}
// SendPush sends a push notification to a user.
func (h *Handler) SendPush(ctx context.Context, userID string, payload map[string]interface{}) {
if h.vapidPub == "" || h.vapidPriv == "" {
return
}
rows, err := h.db.QueryContext(ctx,
`SELECT endpoint, p256dh, auth FROM push_subscriptions WHERE user_id = $1`,
userID,
)
if err != nil {
h.logger.Error("failed to query push subscriptions", "error", err)
return
}
defer rows.Close()
body, _ := json.Marshal(payload)
for rows.Next() {
var endpoint, p256dh, auth string
if err := rows.Scan(&endpoint, &p256dh, &auth); err != nil {
continue
}
sub := &webpush.Subscription{
Endpoint: endpoint,
Keys: webpush.Keys{
P256dh: p256dh,
Auth: auth,
},
}
resp, err := webpush.SendNotification(body, sub, &webpush.Options{
Subscriber: h.vapidSubj,
VAPIDPublicKey: h.vapidPub,
VAPIDPrivateKey: h.vapidPriv,
TTL: 30,
})
if err != nil {
h.logger.Error("failed to send push", "error", err, "user_id", userID)
if resp != nil && resp.StatusCode == 410 {
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
}
continue
}
if resp != nil {
resp.Body.Close()
}
}
}
// SendChannelNotification sends a push notification to all server members (except the author)
// when a new message is posted in a channel.
func (h *Handler) SendChannelNotification(ctx context.Context, channelID, authorID, channelName, content string) {
if h.vapidPub == "" || h.vapidPriv == "" || h.db == nil {
return
}
var serverID string
err := h.db.QueryRowContext(ctx, `SELECT server_id FROM channels WHERE id = $1`, channelID).Scan(&serverID)
if err != nil {
h.logger.Error("failed to find channel server", "error", err)
return
}
h.logger.Info("sending push notifications", "channel", channelName, "author", authorID)
body := content
if len(body) > 200 {
body = body[:200] + "..."
}
payload, _ := json.Marshal(map[string]interface{}{
"title": "New message in #" + channelName,
"body": body,
"url": "/channels/" + channelID,
})
rows, err := h.db.QueryContext(ctx, `
SELECT DISTINCT ps.user_id, ps.endpoint, ps.p256dh, ps.auth
FROM push_subscriptions ps
JOIN members m ON m.user_id = ps.user_id
LEFT JOIN notification_settings ns ON ns.user_id = ps.user_id AND ns.channel_id = $3
WHERE m.server_id = $1 AND ps.user_id != $2
AND (ns.level IS NULL OR ns.level = 'all')
`, serverID, authorID, channelID)
if err != nil {
h.logger.Error("failed to query push subscriptions", "error", err)
return
}
defer rows.Close()
sent := 0
for rows.Next() {
var userID, endpoint, p256dh, auth string
if err := rows.Scan(&userID, &endpoint, &p256dh, &auth); err != nil {
continue
}
sub := &webpush.Subscription{
Endpoint: endpoint,
Keys: webpush.Keys{
P256dh: p256dh,
Auth: auth,
},
}
resp, err := webpush.SendNotification(payload, sub, &webpush.Options{
Subscriber: h.vapidSubj,
VAPIDPublicKey: h.vapidPub,
VAPIDPrivateKey: h.vapidPriv,
TTL: 30,
})
if err != nil {
h.logger.Error("failed to send push", "error", err, "user_id", userID)
if resp != nil && resp.StatusCode == 410 {
h.db.ExecContext(ctx, `DELETE FROM push_subscriptions WHERE endpoint = $1`, endpoint)
}
continue
}
if resp != nil {
resp.Body.Close()
if resp.StatusCode < 300 {
sent++
}
}
}
h.logger.Info("push notifications sent", "count", sent, "channel", channelName)
}
// GenerateVAPIDKeys is in cmd/keygen. Use github.com/SherClockHolmes/webpush-go directly.