teg/backend/push_notifications.go
2026-06-15 20:59:44 +02:00

509 lines
13 KiB
Go

package main
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"html"
"io"
"log"
"net/http"
"net/url"
"strings"
"time"
webpush "github.com/SherClockHolmes/webpush-go"
)
const (
maxPushSubscriptionBodyBytes = 16 * 1024
maxPushIconImageBytes = 3 * 1024 * 1024
)
type pushSubscriptionRequest struct {
Endpoint string `json:"endpoint"`
APIBaseURL string `json:"apiBaseUrl"`
Keys struct {
P256dh string `json:"p256dh"`
Auth string `json:"auth"`
} `json:"keys"`
}
type storedPushSubscription struct {
Endpoint string
P256dh string
Auth string
APIBaseURL string
}
type webPushPayload struct {
Title string `json:"title"`
Body string `json:"body"`
Icon string `json:"icon,omitempty"`
Badge string `json:"badge,omitempty"`
Tag string `json:"tag,omitempty"`
URL string `json:"url"`
}
func (s *Server) webPushConfigured() bool {
return s.vapidPublicKey != "" &&
s.vapidPrivateKey != "" &&
s.vapidSubscriber != ""
}
func (s *Server) handleGetPushConfig(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"enabled": s.webPushConfigured(),
"publicKey": s.vapidPublicKey,
})
}
func (s *Server) handleSavePushSubscription(w http.ResponseWriter, r *http.Request) {
if !s.webPushConfigured() {
writeError(w, http.StatusServiceUnavailable, "Web Push ist auf dem Server noch nicht konfiguriert")
return
}
user, userOK := userFromContext(r.Context())
sessionID, sessionOK := s.currentSessionIDFromRequest(r)
if !userOK || !sessionOK {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscriptionBodyBytes)
var input pushSubscriptionRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement")
return
}
input.Endpoint = strings.TrimSpace(input.Endpoint)
input.Keys.P256dh = strings.TrimSpace(input.Keys.P256dh)
input.Keys.Auth = strings.TrimSpace(input.Keys.Auth)
apiBaseURL, err := normalizePushAPIBaseURL(input.APIBaseURL)
if err != nil ||
input.Endpoint == "" ||
input.Keys.P256dh == "" ||
input.Keys.Auth == "" {
writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement")
return
}
endpointURL, err := url.Parse(input.Endpoint)
if err != nil ||
endpointURL.Host == "" ||
(endpointURL.Scheme != "http" && endpointURL.Scheme != "https") {
writeError(w, http.StatusBadRequest, "Ungültiger Push-Endpunkt")
return
}
_, err = s.db.Exec(
r.Context(),
`
INSERT INTO push_subscriptions (
endpoint,
user_id,
session_id,
p256dh,
auth,
api_base_url,
user_agent,
expires_at
)
VALUES ($1, $2, $3, $4, $5, $6, $7, now() + interval '24 hours')
ON CONFLICT (endpoint)
DO UPDATE SET
user_id = EXCLUDED.user_id,
session_id = EXCLUDED.session_id,
p256dh = EXCLUDED.p256dh,
auth = EXCLUDED.auth,
api_base_url = EXCLUDED.api_base_url,
user_agent = EXCLUDED.user_agent,
expires_at = EXCLUDED.expires_at,
updated_at = now()
`,
input.Endpoint,
user.ID,
sessionID,
input.Keys.P256dh,
input.Keys.Auth,
apiBaseURL,
strings.TrimSpace(r.UserAgent()),
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Push-Abonnement konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": true})
}
func (s *Server) handleDeletePushSubscription(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
r.Body = http.MaxBytesReader(w, r.Body, maxPushSubscriptionBodyBytes)
var input struct {
Endpoint string `json:"endpoint"`
}
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültiges Push-Abonnement")
return
}
_, err := s.db.Exec(
r.Context(),
`DELETE FROM push_subscriptions WHERE endpoint = $1 AND user_id = $2`,
strings.TrimSpace(input.Endpoint),
user.ID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Push-Abonnement konnte nicht entfernt werden")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"subscribed": false})
}
func normalizePushAPIBaseURL(value string) (string, error) {
value = strings.TrimRight(strings.TrimSpace(value), "/")
if value == "" || len(value) > 500 {
return "", errors.New("ungültige API-URL")
}
parsed, err := url.Parse(value)
if err != nil ||
(parsed.Scheme != "http" && parsed.Scheme != "https") ||
parsed.Host == "" {
return "", errors.New("ungültige API-URL")
}
return value, nil
}
func (s *Server) publishWebPushAsync(notification Notification) {
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := s.publishWebPush(ctx, notification); err != nil {
log.Printf("Web Push für User %s fehlgeschlagen: %v", notification.UserID, err)
}
}()
}
func (s *Server) publishWebPush(ctx context.Context, notification Notification) error {
rows, err := s.db.Query(
ctx,
`
SELECT
ps.endpoint,
ps.p256dh,
ps.auth,
ps.api_base_url
FROM push_subscriptions ps
JOIN user_sessions us ON us.id = ps.session_id
WHERE ps.user_id = $1
AND us.revoked_at IS NULL
AND ps.expires_at > now()
`,
notification.UserID,
)
if err != nil {
return err
}
defer rows.Close()
subscriptions := []storedPushSubscription{}
for rows.Next() {
var subscription storedPushSubscription
if err := rows.Scan(
&subscription.Endpoint,
&subscription.P256dh,
&subscription.Auth,
&subscription.APIBaseURL,
); err != nil {
return err
}
subscriptions = append(subscriptions, subscription)
}
if err := rows.Err(); err != nil {
return err
}
for _, subscription := range subscriptions {
payload, err := s.buildWebPushPayload(notification, subscription.APIBaseURL)
if err != nil {
continue
}
payloadJSON, err := json.Marshal(payload)
if err != nil {
continue
}
response, err := webpush.SendNotificationWithContext(
ctx,
payloadJSON,
&webpush.Subscription{
Endpoint: subscription.Endpoint,
Keys: webpush.Keys{
P256dh: subscription.P256dh,
Auth: subscription.Auth,
},
},
&webpush.Options{
Subscriber: s.vapidSubscriber,
VAPIDPublicKey: s.vapidPublicKey,
VAPIDPrivateKey: s.vapidPrivateKey,
TTL: 60 * 60,
HTTPClient: &http.Client{Timeout: 12 * time.Second},
},
)
if err != nil {
continue
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if response.StatusCode == http.StatusGone ||
response.StatusCode == http.StatusNotFound {
cleanupCtx, cleanupCancel := context.WithTimeout(
context.Background(),
5*time.Second,
)
_, _ = s.db.Exec(
cleanupCtx,
`DELETE FROM push_subscriptions WHERE endpoint = $1`,
subscription.Endpoint,
)
cleanupCancel()
}
}
return nil
}
func (s *Server) buildWebPushPayload(
notification Notification,
apiBaseURL string,
) (webPushPayload, error) {
var data struct {
SenderID string `json:"senderId"`
SenderAvatar string `json:"senderAvatar"`
ConversationType string `json:"conversationType"`
ConversationImage string `json:"conversationImage"`
}
if err := json.Unmarshal(notification.Data, &data); err != nil {
return webPushPayload{}, err
}
path := "/chat/" + notification.EntityID
if data.ConversationType == "channel" {
path = "/chat/channel/" + notification.EntityID
}
iconKind := "user"
iconID := data.SenderID
iconSource := strings.TrimSpace(data.SenderAvatar)
if data.ConversationType == "group" {
iconKind = "group"
iconID = notification.EntityID
iconSource = strings.TrimSpace(data.ConversationImage)
} else if data.ConversationType == "channel" || iconID == "" {
iconKind = "channel"
iconID = notification.EntityID
iconSource = strings.TrimSpace(data.ConversationImage)
}
// Externe Bild-URLs direkt verwenden. Für lokale Avatare (data:-URLs) oder
// fehlende Avatare auf den signierten Icon-Endpunkt verweisen, der entweder
// das hinterlegte Bild ausliefert oder ein Initialen-Icon erzeugt.
iconURL := iconSource
isRemote := strings.HasPrefix(iconURL, "https://") ||
strings.HasPrefix(iconURL, "http://")
if !isRemote {
iconURL = ""
if apiBaseURL != "" && iconID != "" {
signature := s.signPushIcon(iconKind, iconID)
iconURL = apiBaseURL + "/push/icons/" + iconKind + "/" + iconID + "/" + signature
iconURL += "?v=" + pushIconVersion(iconSource)
}
}
return webPushPayload{
Title: notification.Title,
Body: notification.Message,
Icon: iconURL,
Tag: "chat-" + notification.EntityID,
URL: path,
}, nil
}
func pushIconVersion(image string) string {
sum := sha256.Sum256([]byte(image))
return hex.EncodeToString(sum[:8])
}
func (s *Server) signPushIcon(kind string, id string) string {
mac := hmac.New(sha256.New, s.jwtSecret)
_, _ = mac.Write([]byte(kind + ":" + id))
return hex.EncodeToString(mac.Sum(nil))
}
func (s *Server) handlePushIcon(w http.ResponseWriter, r *http.Request) {
kind := strings.TrimSpace(r.PathValue("kind"))
id := strings.TrimSpace(r.PathValue("id"))
signature := strings.TrimSpace(r.PathValue("signature"))
expectedSignature := s.signPushIcon(kind, id)
if subtle.ConstantTimeCompare(
[]byte(signature),
[]byte(expectedSignature),
) != 1 {
http.NotFound(w, r)
return
}
var avatar string
var label string
var err error
switch kind {
case "user":
err = s.db.QueryRow(
r.Context(),
`SELECT COALESCE(avatar, ''), COALESCE(NULLIF(display_name, ''), username, '') FROM users WHERE id = $1`,
id,
).Scan(&avatar, &label)
case "channel":
err = s.db.QueryRow(
r.Context(),
`
SELECT COALESCE(channel_image, ''), COALESCE(name, '')
FROM conversations
WHERE id = $1 AND type = 'channel'
`,
id,
).Scan(&avatar, &label)
case "group":
err = s.db.QueryRow(
r.Context(),
`
SELECT COALESCE(channel_image, ''), COALESCE(name, '')
FROM conversations
WHERE id = $1 AND type = 'group'
`,
id,
).Scan(&avatar, &label)
default:
http.NotFound(w, r)
return
}
if err != nil {
http.NotFound(w, r)
return
}
if avatar != "" {
if contentType, image, decodeErr := decodeAvatarDataURL(avatar); decodeErr == nil {
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(image)
return
}
}
// Kein (dekodierbares) Bild hinterlegt: deterministisches Initialen-Icon
// erzeugen, damit die Benachrichtigung trotzdem den Absender zeigt.
w.Header().Set("Content-Type", "image/svg+xml")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Header().Set("X-Content-Type-Options", "nosniff")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(initialsIconSVG(label, kind+":"+id))
}
func initialsIconSVG(label string, seed string) []byte {
initials := deriveInitials(label)
hue := hueFromSeed(seed)
background := fmt.Sprintf("hsl(%d, 60%%, 45%%)", hue)
svg := fmt.Sprintf(
`<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" viewBox="0 0 192 192">`+
`<rect width="192" height="192" rx="40" fill="%s"/>`+
`<text x="96" y="96" dy="0.35em" text-anchor="middle" `+
`font-family="Segoe UI,Roboto,Helvetica,Arial,sans-serif" `+
`font-size="86" font-weight="600" fill="#ffffff">%s</text>`+
`</svg>`,
background,
html.EscapeString(initials),
)
return []byte(svg)
}
func deriveInitials(label string) string {
fields := strings.Fields(label)
if len(fields) == 0 {
return "?"
}
firstRune := func(value string) string {
for _, r := range value {
return strings.ToUpper(string(r))
}
return ""
}
if len(fields) == 1 {
runes := []rune(fields[0])
if len(runes) >= 2 {
return strings.ToUpper(string(runes[:2]))
}
return strings.ToUpper(string(runes))
}
return firstRune(fields[0]) + firstRune(fields[len(fields)-1])
}
func hueFromSeed(seed string) int {
hash := fnv.New32a()
_, _ = hash.Write([]byte(seed))
return int(hash.Sum32() % 360)
}
func decodeAvatarDataURL(value string) (string, []byte, error) {
metadata, encoded, found := strings.Cut(value, ",")
if !found || !strings.HasSuffix(metadata, ";base64") {
return "", nil, errors.New("ungültiges Avatarformat")
}
contentType := strings.TrimSuffix(strings.TrimPrefix(metadata, "data:"), ";base64")
switch contentType {
case "image/png", "image/jpeg", "image/webp", "image/gif":
default:
return "", nil, errors.New("nicht unterstütztes Avatarformat")
}
image, err := base64.StdEncoding.DecodeString(encoded)
if err != nil || len(image) == 0 || len(image) > maxPushIconImageBytes {
return "", nil, errors.New("ungültige Avatardaten")
}
return contentType, image, nil
}