teg/backend/chat_groups.go
2026-07-03 16:56:20 +02:00

841 lines
22 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// backend/chat_groups.go
//
// Gruppenchats (Typ 'group') sowie selbstlöschende Nachrichten und die daraus
// resultierenden System-Nachrichten. Gruppen werden ausschließlich vom
// Ersteller/Owner (conversations.created_by) verwaltet.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
const (
maxGroupNameLength = 80
maxGroupImageBytes = 3 * 1024 * 1024
)
// Erlaubte Selbstlösch-Zeiträume in Sekunden: Aus, 1 Stunde, 1 Tag, 1 Woche.
var allowedDisappearingSeconds = map[int]bool{
0: true,
3600: true,
86400: true,
604800: true,
}
type createGroupRequest struct {
Name string `json:"name"`
Image string `json:"image"`
MemberIDs []string `json:"memberIds"`
IsPublic bool `json:"isPublic"`
}
type updateGroupRequest struct {
Name *string `json:"name"`
Image *string `json:"image"`
IsPublic *bool `json:"isPublic"`
}
type addGroupMembersRequest struct {
UserIDs []string `json:"userIds"`
}
type updateDisappearingRequest struct {
Seconds int `json:"seconds"`
}
func conversationActorName(u User) string {
if name := strings.TrimSpace(u.DisplayName); name != "" {
return name
}
if name := strings.TrimSpace(u.Username); name != "" {
return name
}
return u.Email
}
func disappearingDurationLabel(seconds int) string {
switch seconds {
case 3600:
return "1 Stunde"
case 86400:
return "1 Tag"
case 604800:
return "1 Woche"
default:
return ""
}
}
// startDisappearingMessagesMonitor löscht periodisch abgelaufene
// selbstlöschende Nachrichten (Anhänge werden per FK ON DELETE CASCADE entfernt).
func (s *Server) startDisappearingMessagesMonitor(ctx context.Context) {
s.reconcileDisappearingMessages(ctx)
go func() {
ticker := time.NewTicker(60 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.reconcileDisappearingMessages(ctx)
}
}
}()
}
func (s *Server) reconcileDisappearingMessages(ctx context.Context) {
s.deleteExpiredMessages(ctx)
}
func (s *Server) deleteExpiredMessages(ctx context.Context) {
if _, err := s.db.Exec(
ctx,
`DELETE FROM messages WHERE expires_at IS NOT NULL AND expires_at <= now()`,
); err != nil {
logError("Abgelaufene Nachrichten konnten nicht gelöscht werden", err, nil)
}
}
func canManageGroup(user User, ownerID string) bool {
return ownerID == user.ID || userHasRight(user, "admin")
}
func canManageTeamGroup(user User) bool {
return userHasRight(user, "teams:write")
}
func (s *Server) canManageGroupConversation(ctx context.Context, conversationID string, user User) bool {
if strings.TrimSpace(conversationID) == "" || strings.TrimSpace(user.ID) == "" {
return false
}
var ownerID string
err := s.db.QueryRow(
ctx,
`
SELECT COALESCE(created_by::TEXT, '')
FROM conversations
WHERE id = $1
AND type = 'group'
AND team_id IS NULL
`,
conversationID,
).Scan(&ownerID)
return err == nil && canManageGroup(user, ownerID)
}
func (s *Server) userDisplayName(ctx context.Context, userID string) string {
var name string
err := s.db.QueryRow(
ctx,
`SELECT COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email, 'Unbekannt') FROM users WHERE id = $1`,
userID,
).Scan(&name)
if err != nil || strings.TrimSpace(name) == "" {
return "Unbekannt"
}
return name
}
// createSystemMessage legt eine nicht ablaufende System-Nachricht im Verlauf an.
func (s *Server) createSystemMessage(
ctx context.Context,
conversationID string,
actorID string,
event string,
body string,
data map[string]any,
) (ChatMessage, error) {
payload := map[string]any{"event": event}
for key, value := range data {
payload[key] = value
}
eventJSON, err := json.Marshal(payload)
if err != nil {
return ChatMessage{}, err
}
var messageID string
err = s.db.QueryRow(
ctx,
`
INSERT INTO messages (conversation_id, sender_id, body, message_type, system_event)
VALUES ($1, NULLIF($2, '')::UUID, $3, 'system', $4::JSONB)
RETURNING id::TEXT
`,
conversationID,
actorID,
body,
string(eventJSON),
).Scan(&messageID)
if err != nil {
return ChatMessage{}, err
}
if _, err := s.db.Exec(
ctx,
`UPDATE conversations SET last_message_at = now(), updated_at = now() WHERE id = $1`,
conversationID,
); err != nil {
return ChatMessage{}, err
}
return s.getChatMessage(ctx, messageID)
}
// emitSystemMessage erzeugt eine System-Nachricht und verteilt sie an alle
// Mitglieder (ohne Web-Push, siehe publishChatMessage).
func (s *Server) emitSystemMessage(
ctx context.Context,
conversationID string,
actorID string,
event string,
body string,
data map[string]any,
) {
message, err := s.createSystemMessage(ctx, conversationID, actorID, event, body, data)
if err != nil {
return
}
s.publishChatMessage(ctx, message, "")
}
func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
var input createGroupRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
name := strings.TrimSpace(input.Name)
if name == "" || len([]rune(name)) > maxGroupNameLength {
writeError(w, http.StatusBadRequest, "Gruppenname muss 180 Zeichen lang sein")
return
}
image := strings.TrimSpace(input.Image)
if len(image) > maxGroupImageBytes {
writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß")
return
}
memberIDs := map[string]struct{}{user.ID: {}}
for _, id := range input.MemberIDs {
if id = strings.TrimSpace(id); id != "" {
memberIDs[id] = struct{}{}
}
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
return
}
defer tx.Rollback(r.Context())
var conversationID string
err = tx.QueryRow(
r.Context(),
`
INSERT INTO conversations (type, name, channel_image, created_by, is_public)
VALUES ('group', $1, $2, $3, $4)
RETURNING id::TEXT
`,
name,
image,
user.ID,
input.IsPublic,
).Scan(&conversationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht erstellt werden")
return
}
for id := range memberIDs {
if _, err := tx.Exec(
r.Context(),
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id FROM users WHERE id = $2
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
conversationID,
id,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht gespeichert werden")
return
}
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gespeichert werden")
return
}
// Erst die Konversation an alle Mitglieder verteilen (damit der Chat in der
// Seitenleiste erscheint), dann die "erstellt"-System-Nachricht senden.
s.publishConversationUpdate(r.Context(), conversationID)
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_created",
fmt.Sprintf("%s hat die Gruppe erstellt.", conversationActorName(user)),
nil,
)
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"conversation": conversation})
}
func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
var input updateGroupRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
var conversationType, currentName, currentImage, ownerID, teamID string
var currentIsPublic bool
if err := s.db.QueryRow(
r.Context(),
`
SELECT
type,
name,
channel_image,
COALESCE(created_by::TEXT, ''),
COALESCE(team_id::TEXT, ''),
is_public
FROM conversations
WHERE id = $1
AND type IN ('direct', 'group')
`,
conversationID,
).Scan(&conversationType, &currentName, &currentImage, &ownerID, &teamID, &currentIsPublic); err != nil {
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return
}
if conversationType == "direct" {
if !s.isConversationMember(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Nur Mitglieder dürfen diesen Chat verwalten")
return
}
if input.Name != nil || input.Image != nil {
writeError(w, http.StatusBadRequest, "Name und Bild können bei Einzelchats nicht geändert werden")
return
}
} else if teamID != "" {
if !canManageTeamGroup(user) {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Verwalten des Team-Gruppenchats")
return
}
if input.Name != nil {
writeError(w, http.StatusBadRequest, "Der Name des Team-Gruppenchats wird über das Team verwaltet")
return
}
} else if !canManageGroup(user, ownerID) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe verwalten")
return
}
changed := false
if input.Name != nil {
newName := strings.TrimSpace(*input.Name)
if newName == "" || len([]rune(newName)) > maxGroupNameLength {
writeError(w, http.StatusBadRequest, "Gruppenname muss 180 Zeichen lang sein")
return
}
if newName != currentName {
if _, err := s.db.Exec(
r.Context(),
`UPDATE conversations SET name = $2, updated_at = now() WHERE id = $1`,
conversationID,
newName,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht aktualisiert werden")
return
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_renamed",
fmt.Sprintf("%s hat die Gruppe in „%s“ umbenannt.", conversationActorName(user), newName),
map[string]any{"name": newName},
)
changed = true
}
}
if input.Image != nil {
newImage := strings.TrimSpace(*input.Image)
if len(newImage) > maxGroupImageBytes {
writeError(w, http.StatusBadRequest, "Das Gruppenbild ist zu groß")
return
}
if newImage != currentImage {
if _, err := s.db.Exec(
r.Context(),
`UPDATE conversations SET channel_image = $2, updated_at = now() WHERE id = $1`,
conversationID,
newImage,
); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht aktualisiert werden")
return
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"group_image_changed",
fmt.Sprintf("%s hat das Gruppenbild geändert.", conversationActorName(user)),
nil,
)
changed = true
}
}
if input.IsPublic != nil && *input.IsPublic != currentIsPublic {
if _, err := s.db.Exec(
r.Context(),
`UPDATE conversations SET is_public = $2, updated_at = now() WHERE id = $1`,
conversationID,
*input.IsPublic,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht aktualisiert werden")
return
}
event := "conversation_private"
target := "den Chat"
if conversationType == "group" {
target = "die Gruppe"
}
body := fmt.Sprintf("%s hat %s auf privat gestellt.", conversationActorName(user), target)
if *input.IsPublic {
event = "conversation_public"
body = fmt.Sprintf("%s hat %s öffentlich gemacht.", conversationActorName(user), target)
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
event,
body,
map[string]any{"isPublic": *input.IsPublic},
)
changed = true
}
if changed {
s.publishConversationUpdate(r.Context(), conversationID)
}
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
}
func (s *Server) handleAddGroupMembers(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
if !s.canManageGroupConversation(r.Context(), conversationID, user) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe verwalten")
return
}
var input addGroupMembersRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
added := false
seen := map[string]struct{}{}
for _, rawID := range input.UserIDs {
id := strings.TrimSpace(rawID)
if id == "" {
continue
}
if _, dup := seen[id]; dup {
continue
}
seen[id] = struct{}{}
commandTag, err := s.db.Exec(
r.Context(),
`
INSERT INTO conversation_members (conversation_id, user_id)
SELECT $1, id FROM users WHERE id = $2
ON CONFLICT (conversation_id, user_id) DO NOTHING
`,
conversationID,
id,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Mitglied konnte nicht hinzugefügt werden")
return
}
if commandTag.RowsAffected() == 0 {
continue
}
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"member_added",
fmt.Sprintf("%s hat %s hinzugefügt.", conversationActorName(user), s.userDisplayName(r.Context(), id)),
map[string]any{"targetUserId": id},
)
added = true
}
if added {
s.publishConversationUpdate(r.Context(), conversationID)
}
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
}
func (s *Server) handleRemoveGroupMember(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
targetID := strings.TrimSpace(r.PathValue("userId"))
var convType, ownerID, teamID string
err := s.db.QueryRow(
r.Context(),
`
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
`,
conversationID,
).Scan(&convType, &ownerID, &teamID)
if errors.Is(err, pgx.ErrNoRows) || convType != "group" || teamID != "" {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
isManager := canManageGroup(user, ownerID)
isSelf := targetID == user.ID
if !isManager && !isSelf {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf Mitglieder entfernen")
return
}
if targetID == ownerID {
writeError(w, http.StatusBadRequest, "Der Ersteller kann die Gruppe nicht verlassen")
return
}
commandTag, err := s.db.Exec(
r.Context(),
`DELETE FROM conversation_members WHERE conversation_id = $1 AND user_id = $2`,
conversationID,
targetID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Mitglied konnte nicht entfernt werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Mitglied wurde nicht gefunden")
return
}
if isSelf {
s.emitSystemMessage(
r.Context(),
conversationID,
targetID,
"member_left",
fmt.Sprintf("%s hat die Gruppe verlassen.", conversationActorName(user)),
nil,
)
} else {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"member_removed",
fmt.Sprintf("%s hat %s entfernt.", conversationActorName(user), s.userDisplayName(r.Context(), targetID)),
map[string]any{"targetUserId": targetID},
)
}
// Verbleibende Mitglieder erhalten die aktualisierte Mitgliederliste, der
// entfernte Nutzer erhält einen Hinweis, den Chat zu entfernen.
s.publishConversationUpdate(r.Context(), conversationID)
chatSockets.publish(targetID, chatSocketEvent{
Type: "conversation.removed",
ConversationID: conversationID,
})
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleDeleteGroup(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
var conversationType, ownerID, teamID string
err := s.db.QueryRow(
r.Context(),
`
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
`,
conversationID,
).Scan(&conversationType, &ownerID, &teamID)
if errors.Is(err, pgx.ErrNoRows) || conversationType != "group" || teamID != "" {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden")
return
}
if !canManageGroup(user, ownerID) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe löschen")
return
}
memberIDs, err := s.chatConversationMemberIDs(r.Context(), conversationID)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppenmitglieder konnten nicht geladen werden")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden")
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(
r.Context(),
`DELETE FROM notifications WHERE entity_type = 'chat' AND entity_id = $1`,
conversationID,
); err != nil {
writeError(w, http.StatusInternalServerError, "Chat-Benachrichtigungen konnten nicht gelöscht werden")
return
}
commandTag, err := tx.Exec(
r.Context(),
`DELETE FROM conversations WHERE id = $1 AND type = 'group' AND team_id IS NULL`,
conversationID,
)
if err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden")
return
}
if commandTag.RowsAffected() == 0 {
writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht gelöscht werden")
return
}
for _, memberID := range memberIDs {
chatSockets.publish(memberID, chatSocketEvent{
Type: "conversation.removed",
ConversationID: conversationID,
})
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) handleUpdateDisappearing(w http.ResponseWriter, r *http.Request) {
user, ok := userFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
conversationID := strings.TrimSpace(r.PathValue("id"))
var input updateDisappearingRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Ungültige Anfrage")
return
}
if !allowedDisappearingSeconds[input.Seconds] {
writeError(w, http.StatusBadRequest, "Ungültiger Zeitraum")
return
}
var convType, ownerID, teamID string
err := s.db.QueryRow(
r.Context(),
`
SELECT type, COALESCE(created_by::TEXT, ''), COALESCE(team_id::TEXT, '')
FROM conversations
WHERE id = $1
`,
conversationID,
).Scan(&convType, &ownerID, &teamID)
if errors.Is(err, pgx.ErrNoRows) {
writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return
}
switch convType {
case "direct":
if !s.canAccessConversation(r.Context(), conversationID, user.ID) {
writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat")
return
}
case "group":
if teamID != "" {
if !canManageTeamGroup(user) {
writeError(w, http.StatusForbidden, "Keine Berechtigung zum Ändern selbstlöschender Nachrichten im Team-Gruppenchat")
return
}
} else if !canManageGroup(user, ownerID) {
writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf das Selbstlöschen ändern")
return
}
case "channel":
if !userHasRight(user, "admin") {
writeError(w, http.StatusForbidden, "Nur Administratoren dürfen das Selbstlöschen für Channels ändern")
return
}
default:
writeError(w, http.StatusBadRequest, "Selbstlöschen wird für diesen Chat nicht unterstützt")
return
}
tx, err := s.db.Begin(r.Context())
if err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
defer tx.Rollback(r.Context())
if _, err := tx.Exec(
r.Context(),
`UPDATE conversations SET disappearing_seconds = $2, updated_at = now() WHERE id = $1`,
conversationID,
input.Seconds,
); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
if err := tx.Commit(r.Context()); err != nil {
writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden")
return
}
if input.Seconds == 0 {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"self_delete_disabled",
fmt.Sprintf("%s hat selbstlöschende Nachrichten deaktiviert.", conversationActorName(user)),
map[string]any{"seconds": 0},
)
} else {
s.emitSystemMessage(
r.Context(),
conversationID,
user.ID,
"self_delete_enabled",
fmt.Sprintf(
"%s hat selbstlöschende Nachrichten aktiviert (%s).",
conversationActorName(user),
disappearingDurationLabel(input.Seconds),
),
map[string]any{"seconds": input.Seconds},
)
}
s.publishConversationUpdate(r.Context(), conversationID)
s.publishChatMessagesReload(r.Context(), conversationID)
conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen)
if err != nil {
writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{"conversation": conversation})
}