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( ``+ ``+ `%s`+ ``, 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 }