2026-09-27 23:26:15 +03:00
|
|
|
package api
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
|
|
|
|
"encoding/json"
|
2026-09-28 18:47:57 +03:00
|
|
|
"io"
|
|
|
|
|
"log/slog"
|
2026-09-27 23:26:15 +03:00
|
|
|
"net/http"
|
|
|
|
|
"strings"
|
2026-09-28 18:47:57 +03:00
|
|
|
"time"
|
2026-09-27 23:26:15 +03:00
|
|
|
|
|
|
|
|
"github.com/SherClockHolmes/webpush-go"
|
|
|
|
|
|
|
|
|
|
"messenger/internal/realtime"
|
|
|
|
|
"messenger/internal/store"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
type pushRequest struct {
|
|
|
|
|
Endpoint string `json:"endpoint"`
|
|
|
|
|
Keys struct {
|
|
|
|
|
P256dh string `json:"p256dh"`
|
|
|
|
|
Auth string `json:"auth"`
|
|
|
|
|
} `json:"keys"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *apiHandler) subscribePush(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
userID, _ := userIDFrom(r.Context())
|
|
|
|
|
var req pushRequest
|
|
|
|
|
if !decodeJSON(w, r, &req) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Endpoint == "" || req.Keys.P256dh == "" || req.Keys.Auth == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "endpoint, p256dh and auth are required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
_, err := h.store.CreatePushSubscription(r.Context(), store.PushSubscription{
|
|
|
|
|
UserID: userID,
|
|
|
|
|
Endpoint: req.Endpoint,
|
|
|
|
|
P256dh: req.Keys.P256dh,
|
|
|
|
|
Auth: req.Keys.Auth,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusCreated, map[string]string{"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (h *apiHandler) unsubscribePush(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
userID, _ := userIDFrom(r.Context())
|
|
|
|
|
var req pushRequest
|
|
|
|
|
if !decodeJSON(w, r, &req) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if req.Endpoint == "" {
|
|
|
|
|
writeError(w, http.StatusBadRequest, "endpoint is required")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := h.store.DeletePushSubscription(r.Context(), userID, req.Endpoint); err != nil {
|
|
|
|
|
writeError(w, http.StatusInternalServerError, "internal server error")
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type pushNotifier struct {
|
|
|
|
|
store *store.Store
|
|
|
|
|
hub *realtime.Hub
|
2026-09-28 18:47:57 +03:00
|
|
|
log *slog.Logger
|
2026-09-27 23:26:15 +03:00
|
|
|
public string
|
|
|
|
|
private string
|
|
|
|
|
subject string
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 18:47:57 +03:00
|
|
|
func newPushNotifier(st *store.Store, hub *realtime.Hub, log *slog.Logger, public, private, subject string) *pushNotifier {
|
2026-09-27 23:26:15 +03:00
|
|
|
if public == "" || private == "" {
|
|
|
|
|
return nil
|
|
|
|
|
}
|
2026-09-28 18:47:57 +03:00
|
|
|
return &pushNotifier{store: st, hub: hub, log: log, public: public, private: private, subject: subject}
|
2026-09-27 23:26:15 +03:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// notifyMembers pushes a message notification to offline members.
|
|
|
|
|
// Failed or stale endpoints are cleaned up asynchronously.
|
|
|
|
|
func (n *pushNotifier) notifyMembers(ctx context.Context, conversationID, senderID, messageID int64, senderName, body string) {
|
|
|
|
|
members, err := n.store.ListMembers(ctx, conversationID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
title := senderName
|
|
|
|
|
text := strings.TrimSpace(body)
|
|
|
|
|
if text == "" {
|
|
|
|
|
text = "📎 Вложение"
|
|
|
|
|
}
|
|
|
|
|
if len(text) > 140 {
|
|
|
|
|
text = text[:140] + "…"
|
|
|
|
|
}
|
|
|
|
|
payload, _ := json.Marshal(map[string]any{
|
|
|
|
|
"title": title,
|
|
|
|
|
"body": text,
|
|
|
|
|
"conversation_id": conversationID,
|
|
|
|
|
"message_id": messageID,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
for _, m := range members {
|
|
|
|
|
if m.User.ID == senderID || n.hub.IsOnline(m.User.ID) {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
subs, err := n.store.ListPushSubscriptionsForUser(ctx, m.User.ID)
|
|
|
|
|
if err != nil {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
for _, sub := range subs {
|
2026-09-28 18:47:57 +03:00
|
|
|
go n.send(m.User.ID, sub, payload)
|
2026-09-27 23:26:15 +03:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-28 18:47:57 +03:00
|
|
|
// send delivers a single notification using a detached context, because the
|
|
|
|
|
// originating HTTP request context is canceled as soon as its handler returns.
|
|
|
|
|
func (n *pushNotifier) send(userID int64, sub store.PushSubscription, payload []byte) {
|
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
|
|
|
defer cancel()
|
|
|
|
|
|
2026-09-27 23:26:15 +03:00
|
|
|
wp := &webpush.Subscription{Endpoint: sub.Endpoint, Keys: webpush.Keys{P256dh: sub.P256dh, Auth: sub.Auth}}
|
|
|
|
|
resp, err := webpush.SendNotificationWithContext(ctx, payload, wp, &webpush.Options{
|
|
|
|
|
VAPIDPublicKey: n.public,
|
|
|
|
|
VAPIDPrivateKey: n.private,
|
|
|
|
|
Subscriber: n.subject,
|
|
|
|
|
TTL: 24 * 60 * 60,
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
2026-09-28 18:47:57 +03:00
|
|
|
n.log.Warn("push send failed", "user_id", userID, "error", err)
|
2026-09-27 23:26:15 +03:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
2026-09-28 18:47:57 +03:00
|
|
|
switch {
|
|
|
|
|
case resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone:
|
2026-09-27 23:26:15 +03:00
|
|
|
_ = n.store.DeletePushSubscription(context.Background(), userID, sub.Endpoint)
|
2026-09-28 18:47:57 +03:00
|
|
|
case resp.StatusCode >= 300:
|
|
|
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
|
|
|
n.log.Warn("push rejected", "user_id", userID, "status", resp.StatusCode, "body", string(body))
|
2026-09-27 23:26:15 +03:00
|
|
|
}
|
|
|
|
|
}
|