273 lines
7.4 KiB
Go
273 lines
7.4 KiB
Go
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
|
|
WHERE m.server_id = $1 AND ps.user_id != $2
|
|
`, serverID, authorID)
|
|
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
|
|
}
|