package push import ( "context" "crypto/rand" "database/sql" "encoding/base64" "encoding/json" "log/slog" "net/http" "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 *http.ServeMux) { r.HandleFunc("POST /push/subscribe", h.Subscribe) r.HandleFunc("POST /push/unsubscribe", h.Unsubscribe) r.HandleFunc("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 == "" { 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 } 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() 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() } } } // GenerateVAPIDKeys generates a new VAPID key pair. func GenerateVAPIDKeys() (publicKey, privateKey string, err error) { privateKeyBytes := make([]byte, 32) _, err = rand.Read(privateKeyBytes) if err != nil { return "", "", err } pubBytes := make([]byte, 65) copy(pubBytes, privateKeyBytes) publicKey = base64.RawURLEncoding.EncodeToString(pubBytes) privateKey = base64.RawURLEncoding.EncodeToString(privateKeyBytes) return publicKey, privateKey, nil }