509 lines
13 KiB
Go
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
|
|
}
|