From 182f04bdad902c1197e6cc28befd0dc4005317af Mon Sep 17 00:00:00 2001 From: Linrador <68631622+Linrador@users.noreply.github.com> Date: Fri, 3 Jul 2026 16:56:20 +0200 Subject: [PATCH] updated --- backend/admin_group_chats.go | 18 +- backend/admin_milestone.go | 91 + backend/chat.go | 371 ++- backend/chat_clear.go | 169 ++ backend/chat_groups.go | 128 +- backend/chat_preferences.go | 14 +- backend/chat_search.go | 20 +- backend/chat_unread.go | 12 +- backend/chat_websocket.go | 3 +- backend/device_lookups.go | 36 + backend/devices.go | 102 + backend/go.mod | 4 +- backend/go.sum | 7 + backend/milestone_backup.go | 1191 ++++++++++ backend/operations_milestone_setup.go | 289 ++- backend/routes.go | 10 + backend/runtime_schema.go | 37 + backend/search.go | 116 +- backend/setup/main.go | 62 + frontend/src/App.tsx | 109 +- frontend/src/components/AppLayout.tsx | 3 + frontend/src/components/Feedback.tsx | 49 +- frontend/src/components/Journal.tsx | 2 +- .../src/components/NotificationCenter.tsx | 7 +- frontend/src/components/PinnedChatDock.tsx | 1785 ++++++++++++++ frontend/src/components/Scrollbar.tsx | 48 + frontend/src/components/SearchOverlay.tsx | 11 +- frontend/src/components/Sidebar.tsx | 219 +- frontend/src/components/Tables.tsx | 300 ++- frontend/src/components/Tabs.tsx | 19 +- frontend/src/components/calendar/Calendar.tsx | 101 +- .../components/calendar/calendarSettings.ts | 8 +- frontend/src/index.css | 45 +- frontend/src/pages/DashboardPage.tsx | 5 +- .../administration/AdministrationPage.tsx | 4 +- .../channels/ChannelsAdministration.tsx | 4 +- .../groups/GroupChatsAdministration.tsx | 26 +- ...estone.tsx => MilestoneAdministration.tsx} | 731 +++++- .../users/UsersAdministration.tsx | 32 +- frontend/src/pages/calendar/CalendarPage.tsx | 23 +- frontend/src/pages/chat/ChatPage.tsx | 2043 +++++++++++++++-- .../src/pages/devices/CreateDeviceModal.tsx | 23 +- .../src/pages/devices/DeviceCategoryPage.tsx | 41 + .../src/pages/devices/DeviceFormFields.tsx | 155 +- frontend/src/pages/devices/DevicesPage.tsx | 752 +++++- .../src/pages/devices/EditDeviceModal.tsx | 101 +- .../src/pages/milestone/MilestonePage.tsx | 40 +- .../milestone/backup/MilestoneBackupPage.tsx | 590 +++++ .../geraete/MilestoneDevicesPage.tsx | 8 +- .../milestone/rollen/MilestoneRolesPage.tsx | 221 +- .../pages/operations/CreateOperationModal.tsx | 346 ++- frontend/src/pages/settings/konto/Konto.tsx | 2 +- frontend/src/utils/activityLog.ts | 198 +- 53 files changed, 9660 insertions(+), 1071 deletions(-) create mode 100644 backend/chat_clear.go create mode 100644 backend/milestone_backup.go create mode 100644 frontend/src/components/PinnedChatDock.tsx create mode 100644 frontend/src/components/Scrollbar.tsx rename frontend/src/pages/administration/milestone/{Milestone.tsx => MilestoneAdministration.tsx} (56%) create mode 100644 frontend/src/pages/devices/DeviceCategoryPage.tsx create mode 100644 frontend/src/pages/milestone/backup/MilestoneBackupPage.tsx diff --git a/backend/admin_group_chats.go b/backend/admin_group_chats.go index 683d291..e53218f 100644 --- a/backend/admin_group_chats.go +++ b/backend/admin_group_chats.go @@ -16,15 +16,17 @@ type AdminGroupChat struct { Image string `json:"image"` OwnerID string `json:"ownerId"` TeamID string `json:"teamId"` + IsPublic bool `json:"isPublic"` UserIDs []string `json:"userIds"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } type saveAdminGroupChatRequest struct { - Name string `json:"name"` - Image string `json:"image"` - UserIDs []string `json:"userIds"` + Name string `json:"name"` + Image string `json:"image"` + IsPublic bool `json:"isPublic"` + UserIDs []string `json:"userIds"` } func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Request) { @@ -37,6 +39,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques c.channel_image, COALESCE(c.created_by::TEXT, ''), COALESCE(c.team_id::TEXT, ''), + c.is_public, COALESCE( array_agg(cm.user_id::TEXT ORDER BY cm.created_at) FILTER (WHERE cm.user_id IS NOT NULL), @@ -66,6 +69,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques &group.Image, &group.OwnerID, &group.TeamID, + &group.IsPublic, &group.UserIDs, &group.CreatedAt, &group.UpdatedAt, @@ -132,10 +136,11 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque defer tx.Rollback(r.Context()) var currentName, currentImage, ownerID string + var currentIsPublic bool if err := tx.QueryRow( r.Context(), ` - SELECT name, channel_image, COALESCE(created_by::TEXT, '') + SELECT name, channel_image, COALESCE(created_by::TEXT, ''), is_public FROM conversations WHERE id = $1 AND type = 'group' @@ -143,7 +148,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque FOR UPDATE `, conversationID, - ).Scan(¤tName, ¤tImage, &ownerID); err != nil { + ).Scan(¤tName, ¤tImage, &ownerID, ¤tIsPublic); err != nil { if err == pgx.ErrNoRows { writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden") return @@ -173,6 +178,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque SET name = $2, channel_image = $3, + is_public = $4, updated_at = now() WHERE id = $1 AND type = 'group' @@ -181,6 +187,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque conversationID, input.Name, input.Image, + input.IsPublic, ); err != nil { writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden") return @@ -229,6 +236,7 @@ func (s *Server) handleAdminUpdateGroupChat(w http.ResponseWriter, r *http.Reque changed := currentName != input.Name || currentImage != input.Image || + currentIsPublic != input.IsPublic || !sameStringSet(oldMemberIDs, newMemberIDs) if changed { s.emitSystemMessage( diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index 1b6fb2b..405eb76 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -43,6 +43,12 @@ type milestoneSettingsResponse struct { ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` + BackupNasHost string `json:"backupNasHost"` + BackupNasShare string `json:"backupNasShare"` + BackupNasUsername string `json:"backupNasUsername"` + BackupNasPasswordConfigured bool `json:"backupNasPasswordConfigured"` + BackupLogRootPath string `json:"backupLogRootPath"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` } @@ -58,6 +64,11 @@ type updateMilestoneSettingsRequest struct { ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"` ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` + BackupNasHost string `json:"backupNasHost"` + BackupNasShare string `json:"backupNasShare"` + BackupNasUsername string `json:"backupNasUsername"` + BackupNasPassword string `json:"backupNasPassword"` + BackupLogRootPath string `json:"backupLogRootPath"` } type milestoneCredentials struct { @@ -90,6 +101,11 @@ func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Reque ServerFoldersStorageRootPath: "D:/Milestone-Speicher", ServerFoldersArchiveRootLabel: "Archive E", ServerFoldersArchiveRootPath: "E:/", + BackupNasHost: "", + BackupNasShare: "", + BackupNasUsername: "", + BackupNasPasswordConfigured: false, + BackupLogRootPath: "", }, }) return @@ -125,6 +141,11 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher") serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E") serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/") + backupNasHost := normalizeBackupNasHost(input.BackupNasHost) + backupNasShare := normalizeBackupNasShare(input.BackupNasShare) + backupNasUsername := strings.TrimSpace(input.BackupNasUsername) + backupNasPassword := strings.TrimSpace(input.BackupNasPassword) + backupLogRootPath := strings.TrimSpace(input.BackupLogRootPath) if host == "" { writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") @@ -173,6 +194,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re server_folders_storage_root_path, server_folders_archive_root_label, server_folders_archive_root_path, + backup_log_root_path, server_folders_agent_token_encrypted ) VALUES ( @@ -191,6 +213,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re $9, $10, $11, + $12, NULL ) ON CONFLICT (id) @@ -209,6 +232,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path, server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label, server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path, + backup_log_root_path = EXCLUDED.backup_log_root_path, updated_at = now() `, host, @@ -222,6 +246,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re serverFoldersStorageRootPath, serverFoldersArchiveRootLabel, serverFoldersArchiveRootPath, + backupLogRootPath, ) } else { _, err = s.db.Exec( @@ -242,6 +267,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re server_folders_storage_root_path = $7, server_folders_archive_root_label = $8, server_folders_archive_root_path = $9, + backup_log_root_path = $10, updated_at = now() WHERE id = 1 `, @@ -254,6 +280,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re serverFoldersStorageRootPath, serverFoldersArchiveRootLabel, serverFoldersArchiveRootPath, + backupLogRootPath, ) } @@ -262,6 +289,35 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re return } + _, err = s.db.Exec( + r.Context(), + ` + UPDATE milestone_settings + SET + backup_nas_host = $1, + backup_nas_share = $2, + backup_nas_username = $3, + backup_nas_password_encrypted = CASE + WHEN $1 = '' AND $2 = '' AND $3 = '' AND $4 = '' THEN NULL + WHEN $4 <> '' THEN pgp_sym_encrypt($4, $5) + ELSE backup_nas_password_encrypted + END, + backup_log_root_path = $6, + updated_at = now() + WHERE id = 1 + `, + backupNasHost, + backupNasShare, + backupNasUsername, + backupNasPassword, + encryptionKey, + backupLogRootPath, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht gespeichert werden") + return + } + rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context()) if r.URL.Query().Get("sync") == "false" { @@ -474,6 +530,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'), COALESCE(server_folders_archive_root_label, 'Archive E'), COALESCE(server_folders_archive_root_path, 'E:/'), + COALESCE(backup_nas_host, ''), + COALESCE(backup_nas_share, ''), + COALESCE(backup_nas_username, ''), + backup_nas_password_encrypted IS NOT NULL, + COALESCE(backup_log_root_path, ''), updated_at FROM milestone_settings WHERE id = 1 @@ -499,6 +560,11 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes &settings.ServerFoldersStorageRootPath, &settings.ServerFoldersArchiveRootLabel, &settings.ServerFoldersArchiveRootPath, + &settings.BackupNasHost, + &settings.BackupNasShare, + &settings.BackupNasUsername, + &settings.BackupNasPasswordConfigured, + &settings.BackupLogRootPath, &updatedAt, ) @@ -659,6 +725,31 @@ func normalizeServerFoldersRootPath(value string, fallback string) string { return strings.ReplaceAll(value, `\`, "/") } +func normalizeBackupNasHost(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, `\\`) + value = strings.TrimPrefix(value, "//") + value = strings.TrimPrefix(value, "smb://") + value = strings.Trim(value, `/\`) + + if slashIndex := strings.IndexAny(value, `/\`); slashIndex >= 0 { + value = value[:slashIndex] + } + + return strings.TrimSpace(value) +} + +func normalizeBackupNasShare(value string) string { + value = strings.TrimSpace(value) + value = strings.Trim(value, `/\`) + + if slashIndex := strings.IndexAny(value, `/\`); slashIndex >= 0 { + value = value[:slashIndex] + } + + return strings.TrimSpace(value) +} + func normalizeMilestoneHost(value string) string { value = strings.TrimSpace(value) value = strings.TrimRight(value, "/") diff --git a/backend/chat.go b/backend/chat.go index 4fd1bc4..b0bf014 100644 --- a/backend/chat.go +++ b/backend/chat.go @@ -43,10 +43,22 @@ type ChatMessage struct { ForwardedFrom *ChatMessageReference `json:"forwardedFrom"` Location *ChatLocation `json:"location"` LinkPreview *ChatLinkPreview `json:"linkPreview"` + Reactions []ChatReaction `json:"reactions"` replyToMessageID string forwardedFromMessageID string } +type ChatReaction struct { + Emoji string `json:"emoji"` + Users []ChatReactionUser `json:"users"` + Count int `json:"count"` +} + +type ChatReactionUser struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` +} + type ChatLocation struct { Lat float64 `json:"lat"` Lng float64 `json:"lng"` @@ -83,6 +95,8 @@ type ChatConversation struct { Image string `json:"image"` TeamID string `json:"teamId"` OwnerID string `json:"ownerId"` + IsPublic bool `json:"isPublic"` + IsMember bool `json:"isMember"` DisappearingSeconds int `json:"disappearingSeconds"` Participants []ChatUser `json:"participants"` LastMessage *ChatMessage `json:"lastMessage"` @@ -112,6 +126,10 @@ type createChatMessageInput struct { Location *ChatLocation } +type updateChatReactionRequest struct { + Emoji string `json:"emoji"` +} + var ( errChatAccessDenied = errors.New("kein zugriff auf diesen chat") errChatMessageEmpty = errors.New("nachricht darf nicht leer sein") @@ -269,7 +287,20 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ c.name, c.channel_image, COALESCE(c.team_id::TEXT, ''), - c.last_message_at, + ( + SELECT MAX(last_visible_message.created_at) + FROM messages last_visible_message + LEFT JOIN conversation_members last_visible_membership + ON last_visible_membership.conversation_id = c.id + AND last_visible_membership.user_id = $1 + WHERE last_visible_message.conversation_id = c.id + AND last_visible_message.deleted_at IS NULL + AND (last_visible_message.expires_at IS NULL OR last_visible_message.expires_at > now()) + AND last_visible_message.created_at > COALESCE( + last_visible_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) + ), COALESCE(( SELECT cm.muted FROM conversation_members cm @@ -287,6 +318,11 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ AND unread_message.message_type <> 'system' AND unread_message.sender_id IS DISTINCT FROM $1::UUID AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now()) + AND current_membership.user_id IS NOT NULL + AND unread_message.created_at > COALESCE( + current_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) AND unread_message.created_at > COALESCE( current_membership.last_read_at, '-infinity'::TIMESTAMPTZ @@ -294,13 +330,23 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ ), 0), c.created_at, COALESCE(c.created_by::TEXT, ''), + c.is_public, + EXISTS ( + SELECT 1 + FROM conversation_members current_user_membership + WHERE current_user_membership.conversation_id = c.id + AND current_user_membership.user_id = $1 + ), c.disappearing_seconds FROM conversations c - WHERE EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = $1 + WHERE ( + EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ) + OR c.is_public = true ) ORDER BY COALESCE(c.last_message_at, c.created_at) DESC `, @@ -326,6 +372,8 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ &conversation.UnreadCount, &conversation.CreatedAt, &conversation.OwnerID, + &conversation.IsPublic, + &conversation.IsMember, &conversation.DisappearingSeconds, ); err != nil { writeError(w, http.StatusInternalServerError, "Chats konnten nicht gelesen werden") @@ -339,7 +387,7 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ } conversation.Participants = participants - lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID) + lastMessage, err := s.getLastChatMessage(r.Context(), conversation.ID, user.ID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusInternalServerError, "Letzte Nachricht konnte nicht geladen werden") return @@ -455,7 +503,7 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request) } conversationID := strings.TrimSpace(r.PathValue("id")) - if !s.canAccessConversation(r.Context(), conversationID, user.ID) { + if !s.isConversationMember(r.Context(), conversationID, user.ID) { writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") return } @@ -516,13 +564,21 @@ func (s *Server) handleListChatMessages(w http.ResponseWriter, r *http.Request) FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id + LEFT JOIN conversation_members viewer_membership + ON viewer_membership.conversation_id = m.conversation_id + AND viewer_membership.user_id = $2 WHERE m.conversation_id = $1 AND m.deleted_at IS NULL AND (m.expires_at IS NULL OR m.expires_at > now()) + AND m.created_at > COALESCE( + viewer_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) ORDER BY m.created_at ASC, m.id ASC LIMIT 200 `, conversationID, + user.ID, ) if err != nil { writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht geladen werden") @@ -599,6 +655,142 @@ func (s *Server) handleCreateChatMessage(w http.ResponseWriter, r *http.Request) writeJSON(w, http.StatusCreated, map[string]any{"message": message}) } +func normalizeChatReactionEmoji(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + runes := []rune(value) + if len(runes) > 8 { + return "" + } + + return value +} + +func (s *Server) handleToggleChatMessageReaction(w http.ResponseWriter, r *http.Request) { + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + messageID := strings.TrimSpace(r.PathValue("id")) + + var input updateChatReactionRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + emoji := normalizeChatReactionEmoji(input.Emoji) + if emoji == "" { + writeError(w, http.StatusBadRequest, "Emoji ist erforderlich") + return + } + + var conversationID string + err := s.db.QueryRow( + r.Context(), + ` + SELECT conversation_id::TEXT + FROM messages + WHERE id = $1 + AND deleted_at IS NULL + AND message_type <> 'system' + AND (expires_at IS NULL OR expires_at > now()) + `, + messageID, + ).Scan(&conversationID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Nachricht wurde nicht gefunden") + return + } + writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht geladen werden") + return + } + + if !s.isConversationMember(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden") + return + } + defer tx.Rollback(r.Context()) + + var existingEmoji string + err = tx.QueryRow( + r.Context(), + ` + SELECT emoji + FROM message_reactions + WHERE message_id = $1 + AND user_id = $2 + ORDER BY created_at DESC + LIMIT 1 + `, + messageID, + user.ID, + ).Scan(&existingEmoji) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht geladen werden") + return + } + + if _, err := tx.Exec( + r.Context(), + ` + DELETE FROM message_reactions + WHERE message_id = $1 + AND user_id = $2 + `, + messageID, + user.ID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht entfernt werden") + return + } + + if existingEmoji != emoji { + if _, err := tx.Exec( + r.Context(), + ` + INSERT INTO message_reactions (message_id, user_id, emoji) + VALUES ($1, $2, $3) + ON CONFLICT (message_id, user_id) DO UPDATE + SET emoji = EXCLUDED.emoji, + created_at = now() + `, + messageID, + user.ID, + emoji, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden") + return + } + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Reaktion konnte nicht gespeichert werden") + return + } + + message, err := s.getChatMessage(r.Context(), messageID) + if err != nil { + writeError(w, http.StatusInternalServerError, "Nachricht konnte nicht aktualisiert werden") + return + } + + s.publishChatMessageUpdate(r.Context(), message) + writeJSON(w, http.StatusOK, map[string]any{"message": message}) +} + func (s *Server) createChatMessage( ctx context.Context, user User, @@ -614,6 +806,9 @@ func (s *Server) createChatMessage( if !s.isConversationWritable(ctx, conversationID) { return message, errChatReadOnly } + if !s.isConversationMember(ctx, conversationID, user.ID) { + return message, errChatAccessDenied + } if replyToMessageID := strings.TrimSpace(input.ReplyToMessageID); replyToMessageID != "" { var valid bool @@ -857,11 +1052,14 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin SELECT 1 FROM conversations c WHERE c.id = $1 - AND EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = $2 + AND ( + EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $2 + ) + OR c.is_public = true ) ) `, @@ -872,6 +1070,55 @@ func (s *Server) canAccessConversation(ctx context.Context, conversationID strin return err == nil && allowed } +func (s *Server) isConversationMember(ctx context.Context, conversationID string, userID string) bool { + if conversationID == "" || userID == "" { + return false + } + + var member bool + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = $1 + AND cm.user_id = $2 + ) + `, + conversationID, + userID, + ).Scan(&member) + + return err == nil && member +} + +func (s *Server) canPostToConversation(ctx context.Context, conversationID string, userID string) bool { + if conversationID == "" || userID == "" { + return false + } + + var allowed bool + err := s.db.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM conversations c + JOIN conversation_members cm + ON cm.conversation_id = c.id + AND cm.user_id = $2 + WHERE c.id = $1 + AND c.type <> 'channel' + ) + `, + conversationID, + userID, + ).Scan(&allowed) + + return err == nil && allowed +} + func (s *Server) isConversationWritable(ctx context.Context, conversationID string) bool { var writable bool err := s.db.QueryRow( @@ -988,7 +1235,20 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, c.name, c.channel_image, COALESCE(c.team_id::TEXT, ''), - c.last_message_at, + ( + SELECT MAX(last_visible_message.created_at) + FROM messages last_visible_message + LEFT JOIN conversation_members last_visible_membership + ON last_visible_membership.conversation_id = c.id + AND last_visible_membership.user_id = $2 + WHERE last_visible_message.conversation_id = c.id + AND last_visible_message.deleted_at IS NULL + AND (last_visible_message.expires_at IS NULL OR last_visible_message.expires_at > now()) + AND last_visible_message.created_at > COALESCE( + last_visible_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) + ), COALESCE(( SELECT cm.muted FROM conversation_members cm @@ -1006,6 +1266,11 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, AND unread_message.message_type <> 'system' AND unread_message.sender_id IS DISTINCT FROM $2::UUID AND (unread_message.expires_at IS NULL OR unread_message.expires_at > now()) + AND current_membership.user_id IS NOT NULL + AND unread_message.created_at > COALESCE( + current_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) AND unread_message.created_at > COALESCE( current_membership.last_read_at, '-infinity'::TIMESTAMPTZ @@ -1013,6 +1278,13 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, ), 0), c.created_at, COALESCE(c.created_by::TEXT, ''), + c.is_public, + EXISTS ( + SELECT 1 + FROM conversation_members current_user_membership + WHERE current_user_membership.conversation_id = c.id + AND current_user_membership.user_id = $2 + ), c.disappearing_seconds FROM conversations c WHERE c.id = $1 @@ -1030,6 +1302,8 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, &conversation.UnreadCount, &conversation.CreatedAt, &conversation.OwnerID, + &conversation.IsPublic, + &conversation.IsMember, &conversation.DisappearingSeconds, ) if err != nil { @@ -1044,7 +1318,7 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, return conversation, err } - lastMessage, err := s.getLastChatMessage(ctx, conversation.ID) + lastMessage, err := s.getLastChatMessage(ctx, conversation.ID, userID) if err == nil { conversation.LastMessage = &lastMessage } else if !errors.Is(err, pgx.ErrNoRows) { @@ -1155,7 +1429,7 @@ func (s *Server) getChatMessage(ctx context.Context, messageID string) (ChatMess return message, err } -func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) (ChatMessage, error) { +func (s *Server) getLastChatMessage(ctx context.Context, conversationID string, userID string) (ChatMessage, error) { row := s.db.QueryRow( ctx, ` @@ -1212,13 +1486,21 @@ func (s *Server) getLastChatMessage(ctx context.Context, conversationID string) FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id + LEFT JOIN conversation_members viewer_membership + ON viewer_membership.conversation_id = m.conversation_id + AND viewer_membership.user_id = $2 WHERE m.conversation_id = $1 AND m.deleted_at IS NULL AND (m.expires_at IS NULL OR m.expires_at > now()) + AND m.created_at > COALESCE( + viewer_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) ORDER BY m.created_at DESC, m.id DESC LIMIT 1 `, conversationID, + userID, ) message, err := scanChatMessage(row) if err != nil { @@ -1247,6 +1529,12 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er } message.LinkPreview = preview + reactions, err := s.listChatReactions(ctx, message.ID) + if err != nil { + return err + } + message.Reactions = reactions + if message.replyToMessageID != "" { reference, err := s.getChatMessageReference(ctx, message.replyToMessageID) if err != nil && !errors.Is(err, pgx.ErrNoRows) { @@ -1270,6 +1558,57 @@ func (s *Server) enrichChatMessage(ctx context.Context, message *ChatMessage) er return nil } +func (s *Server) listChatReactions( + ctx context.Context, + messageID string, +) ([]ChatReaction, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + mr.emoji, + mr.user_id::TEXT, + COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, '') + FROM message_reactions mr + JOIN users u ON u.id = mr.user_id + WHERE mr.message_id = $1 + ORDER BY mr.created_at ASC, mr.user_id ASC + `, + messageID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + reactions := []ChatReaction{} + reactionByEmoji := map[string]int{} + + for rows.Next() { + var emoji string + var reactionUser ChatReactionUser + if err := rows.Scan( + &emoji, + &reactionUser.ID, + &reactionUser.DisplayName, + ); err != nil { + return nil, err + } + + index, ok := reactionByEmoji[emoji] + if !ok { + reactionByEmoji[emoji] = len(reactions) + reactions = append(reactions, ChatReaction{Emoji: emoji}) + index = len(reactions) - 1 + } + + reactions[index].Users = append(reactions[index].Users, reactionUser) + reactions[index].Count = len(reactions[index].Users) + } + + return reactions, rows.Err() +} + func (s *Server) getChatMessageLocation( ctx context.Context, messageID string, diff --git a/backend/chat_clear.go b/backend/chat_clear.go new file mode 100644 index 0000000..51d3dd6 --- /dev/null +++ b/backend/chat_clear.go @@ -0,0 +1,169 @@ +package main + +import ( + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" +) + +type clearChatRequest struct { + Scope string `json:"scope"` +} + +func (s *Server) handleClearChat(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.canAccessConversation(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") + return + } + if !s.isConversationMember(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat leeren") + return + } + + var input clearChatRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Ungültige Anfrage") + return + } + + scope := strings.TrimSpace(input.Scope) + if scope != "me" && scope != "all" { + writeError(w, http.StatusBadRequest, "Ungültiger Löschumfang") + return + } + + 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) { + writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") + return + } + + if scope == "all" { + switch conversationType { + case "direct": + case "group": + if teamID != "" { + if !canManageTeamGroup(user) { + writeError(w, http.StatusForbidden, "Keine Berechtigung zum Leeren dieses Team-Gruppenchats") + return + } + } else if !canManageGroup(user, ownerID) { + writeError(w, http.StatusForbidden, "Nur der Ersteller oder ein Administrator darf die Gruppe für alle leeren") + return + } + default: + writeError(w, http.StatusBadRequest, "Dieser Chat kann nicht für alle geleert werden") + return + } + } + + if scope == "me" { + if _, err := s.db.Exec( + r.Context(), + ` + UPDATE conversation_members + SET last_cleared_at = now(), last_read_at = now() + WHERE conversation_id = $1 + AND user_id = $2 + `, + conversationID, + user.ID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden") + return + } + + 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, + }) + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden") + return + } + defer tx.Rollback(r.Context()) + + if _, err := tx.Exec( + r.Context(), + ` + UPDATE messages + SET deleted_at = now() + WHERE conversation_id = $1 + AND deleted_at IS NULL + `, + conversationID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden") + return + } + + if _, err := tx.Exec( + r.Context(), + ` + UPDATE conversations + SET + last_message_at = ( + SELECT MAX(created_at) + FROM messages + WHERE conversation_id = $1 + AND deleted_at IS NULL + AND (expires_at IS NULL OR expires_at > now()) + ), + updated_at = now() + WHERE id = $1 + `, + conversationID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geleert werden") + return + } + + s.publishChatMessagesReload(r.Context(), conversationID) + 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, + }) +} diff --git a/backend/chat_groups.go b/backend/chat_groups.go index dc3f5c7..6c56248 100644 --- a/backend/chat_groups.go +++ b/backend/chat_groups.go @@ -35,11 +35,13 @@ 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"` + Name *string `json:"name"` + Image *string `json:"image"` + IsPublic *bool `json:"isPublic"` } type addGroupMembersRequest struct { @@ -94,21 +96,6 @@ func (s *Server) startDisappearingMessagesMonitor(ctx context.Context) { } func (s *Server) reconcileDisappearingMessages(ctx context.Context) { - if _, err := s.db.Exec( - ctx, - `UPDATE messages AS m - SET expires_at = m.created_at + make_interval(secs => c.disappearing_seconds) - FROM conversations AS c - WHERE m.conversation_id = c.id - AND c.disappearing_seconds > 0 - AND m.message_type = 'user' - AND m.deleted_at IS NULL - AND m.expires_at IS NULL`, - ); err != nil { - logError("Ablaufzeiten selbstlöschender Nachrichten konnten nicht ergänzt werden", err, nil) - return - } - s.deleteExpiredMessages(ctx) } @@ -269,13 +256,14 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) { err = tx.QueryRow( r.Context(), ` - INSERT INTO conversations (type, name, channel_image, created_by) - VALUES ('group', $1, $2, $3) + 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") @@ -317,7 +305,7 @@ func (s *Server) handleCreateGroup(w http.ResponseWriter, r *http.Request) { conversation, err := s.getChatConversation(r.Context(), conversationID, user.ID, user.ShowLastSeen) if err != nil { - writeError(w, http.StatusInternalServerError, "Gruppe konnte nicht geladen werden") + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") return } @@ -339,26 +327,38 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) { return } - var currentName, currentImage, ownerID, teamID string + 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, '') + COALESCE(team_id::TEXT, ''), + is_public FROM conversations WHERE id = $1 - AND type = 'group' + AND type IN ('direct', 'group') `, conversationID, - ).Scan(¤tName, ¤tImage, &ownerID, &teamID); err != nil { - writeError(w, http.StatusNotFound, "Gruppe wurde nicht gefunden") + ).Scan(&conversationType, ¤tName, ¤tImage, &ownerID, &teamID, ¤tIsPublic); err != nil { + writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden") return } - if teamID != "" { + 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 @@ -430,13 +430,45 @@ func (s *Server) handleUpdateGroup(w http.ResponseWriter, r *http.Request) { } } + 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, "Gruppe konnte nicht geladen werden") + writeError(w, http.StatusInternalServerError, "Chat konnte nicht geladen werden") return } @@ -766,48 +798,6 @@ func (s *Server) handleUpdateDisappearing(w http.ResponseWriter, r *http.Request return } - if input.Seconds == 0 { - if _, err := tx.Exec( - r.Context(), - `UPDATE messages - SET expires_at = NULL - WHERE conversation_id = $1 - AND message_type = 'user' - AND deleted_at IS NULL`, - conversationID, - ); err != nil { - writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") - return - } - } else { - if _, err := tx.Exec( - r.Context(), - `UPDATE messages - SET expires_at = created_at + make_interval(secs => $2) - WHERE conversation_id = $1 - AND message_type = 'user' - AND deleted_at IS NULL`, - conversationID, - input.Seconds, - ); err != nil { - writeError(w, http.StatusInternalServerError, "Einstellung konnte nicht gespeichert werden") - return - } - - if _, err := tx.Exec( - r.Context(), - `DELETE FROM messages - WHERE conversation_id = $1 - AND message_type = 'user' - AND deleted_at IS NULL - AND expires_at <= now()`, - conversationID, - ); 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 diff --git a/backend/chat_preferences.go b/backend/chat_preferences.go index 876830f..d2c6ee1 100644 --- a/backend/chat_preferences.go +++ b/backend/chat_preferences.go @@ -21,18 +21,8 @@ func (s *Server) handleUpdateConversationMute(w http.ResponseWriter, r *http.Req writeError(w, http.StatusForbidden, "Kein Zugriff auf diesen Chat") return } - - var conversationType string - if err := s.db.QueryRow( - r.Context(), - `SELECT type FROM conversations WHERE id = $1`, - conversationID, - ).Scan(&conversationType); err != nil { - writeError(w, http.StatusNotFound, "Chat wurde nicht gefunden") - return - } - if conversationType == "direct" { - writeError(w, http.StatusBadRequest, "Einzelchats können nicht stummgeschaltet werden") + if !s.isConversationMember(r.Context(), conversationID, user.ID) { + writeError(w, http.StatusForbidden, "Nur Mitglieder können diesen Chat stummschalten") return } diff --git a/backend/chat_search.go b/backend/chat_search.go index fb23bc4..fbd790b 100644 --- a/backend/chat_search.go +++ b/backend/chat_search.go @@ -48,15 +48,25 @@ func (s *Server) handleSearchChatMessages(w http.ResponseWriter, r *http.Request FROM messages m JOIN conversations c ON c.id = m.conversation_id LEFT JOIN users u ON u.id = m.sender_id + LEFT JOIN conversation_members viewer_membership + ON viewer_membership.conversation_id = c.id + AND viewer_membership.user_id = $1 WHERE m.deleted_at IS NULL AND m.message_type = 'user' AND (m.expires_at IS NULL OR m.expires_at > now()) AND strpos(lower(m.body), lower($2)) > 0 - AND EXISTS ( - SELECT 1 - FROM conversation_members cm - WHERE cm.conversation_id = c.id - AND cm.user_id = $1 + AND m.created_at > COALESCE( + viewer_membership.last_cleared_at, + '-infinity'::TIMESTAMPTZ + ) + AND ( + EXISTS ( + SELECT 1 + FROM conversation_members cm + WHERE cm.conversation_id = c.id + AND cm.user_id = $1 + ) + OR c.is_public = true ) ORDER BY m.conversation_id, m.created_at DESC, m.id DESC LIMIT 50 diff --git a/backend/chat_unread.go b/backend/chat_unread.go index 51496d5..fe9bb78 100644 --- a/backend/chat_unread.go +++ b/backend/chat_unread.go @@ -14,14 +14,10 @@ func (s *Server) markChatConversationRead( _, err := s.db.Exec( ctx, ` - INSERT INTO conversation_members ( - conversation_id, - user_id, - last_read_at - ) - VALUES ($1, $2, now()) - ON CONFLICT (conversation_id, user_id) - DO UPDATE SET last_read_at = EXCLUDED.last_read_at + UPDATE conversation_members + SET last_read_at = now() + WHERE conversation_id = $1 + AND user_id = $2 `, conversationID, userID, diff --git a/backend/chat_websocket.go b/backend/chat_websocket.go index 5d9e9ed..686bb66 100644 --- a/backend/chat_websocket.go +++ b/backend/chat_websocket.go @@ -183,8 +183,7 @@ func (s *Server) readChatSocket( switch command.Type { case "typing.start", "typing.stop": - if !s.canAccessConversation(ctx, command.ConversationID, user.ID) || - !s.isConversationWritable(ctx, command.ConversationID) { + if !s.canPostToConversation(ctx, command.ConversationID, user.ID) { client.send(chatSocketEvent{ Type: "error", Error: "Dieser Chat ist schreibgeschützt", diff --git a/backend/device_lookups.go b/backend/device_lookups.go index 41663d8..80aedcc 100644 --- a/backend/device_lookups.go +++ b/backend/device_lookups.go @@ -43,6 +43,10 @@ func (s *Server) handleCreateDeviceManufacturer(w http.ResponseWriter, r *http.R s.handleCreateDeviceLookup(w, r, "device_manufacturers", "Hersteller") } +func (s *Server) handleDeleteDeviceManufacturer(w http.ResponseWriter, r *http.Request) { + s.handleDeleteDeviceLookup(w, r, "device_manufacturers", "Hersteller") +} + func (s *Server) handleCreateDeviceLocation(w http.ResponseWriter, r *http.Request) { s.handleCreateDeviceLookup(w, r, "device_locations", "Standort") } @@ -77,6 +81,38 @@ func (s *Server) handleCreateDeviceLookup( }) } +func (s *Server) handleDeleteDeviceLookup( + w http.ResponseWriter, + r *http.Request, + tableName string, + label string, +) { + id := strings.TrimSpace(r.PathValue("id")) + if id == "" { + writeError(w, http.StatusBadRequest, label+" ist erforderlich") + return + } + + commandTag, err := s.db.Exec( + r.Context(), + fmt.Sprintf(`DELETE FROM %s WHERE id = $1`, tableName), + id, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, label+" konnte nicht gelöscht werden") + return + } + + if commandTag.RowsAffected() == 0 { + writeError(w, http.StatusNotFound, label+" wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "deleted": true, + }) +} + func listDeviceLookupOptions( ctx context.Context, db interface { diff --git a/backend/devices.go b/backend/devices.go index fd81cc7..4dbb378 100644 --- a/backend/devices.go +++ b/backend/devices.go @@ -861,6 +861,108 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { }) } +func (s *Server) handleDeleteDevice(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + + if deviceID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") + return + } + + user, ok := userFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") + return + } + defer tx.Rollback(r.Context()) + + var device Device + + err = tx.QueryRow( + r.Context(), + ` + SELECT + id, + COALESCE(inventory_number, ''), + COALESCE(manufacturer, ''), + COALESCE(model, ''), + COALESCE(milestone_recording_server_id, ''), + COALESCE(milestone_hardware_id, ''), + COALESCE(milestone_display_name, '') + FROM devices + WHERE id = $1 + FOR UPDATE + `, + deviceID, + ).Scan( + &device.ID, + &device.InventoryNumber, + &device.Manufacturer, + &device.Model, + &device.MilestoneRecordingServerID, + &device.MilestoneHardwareID, + &device.MilestoneDisplayName, + ) + + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") + return + } + + if err != nil { + writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") + return + } + + if strings.TrimSpace(device.MilestoneHardwareID) != "" || + strings.TrimSpace(device.MilestoneRecordingServerID) != "" { + writeError(w, http.StatusConflict, "Milestone-Geräte können nicht gelöscht werden") + return + } + + if _, err := tx.Exec(r.Context(), `DELETE FROM devices WHERE id = $1`, device.ID); err != nil { + writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Gerät konnte nicht gelöscht werden") + return + } + + if err := s.publishDeviceChangeEvent( + r.Context(), + user.ID, + device.ID, + "device.deleted", + "Gerät gelöscht", + formatText( + "Gerät %s wurde gelöscht.", + device.InventoryNumber, + ), + LogFields{ + "inventoryNumber": device.InventoryNumber, + "manufacturer": device.Manufacturer, + "model": device.Model, + }, + ); err != nil { + logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{ + "deviceId": device.ID, + "type": "device.deleted", + }) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "deletedDeviceId": device.ID, + }) +} + func ensureDeviceExists(ctx context.Context, tx pgx.Tx, deviceID string) error { var exists bool diff --git a/backend/go.mod b/backend/go.mod index 24086c4..ce8b655 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -7,14 +7,16 @@ go 1.26.2 require ( github.com/SherClockHolmes/webpush-go v1.4.0 github.com/coder/websocket v1.8.14 - github.com/golang-jwt/jwt/v5 v5.3.1 github.com/go-ldap/ldap/v3 v3.4.13 + github.com/golang-jwt/jwt/v5 v5.3.1 + github.com/hirochachacha/go-smb2 v1.1.0 github.com/jackc/pgx/v5 v5.9.2 golang.org/x/crypto v0.51.0 ) require ( github.com/Azure/go-ntlmssp v0.1.0 // indirect + github.com/geoffgarside/ber v1.1.0 // indirect github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect github.com/google/uuid v1.6.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index ba6679c..4e9b6b4 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -7,6 +7,8 @@ github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6p github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/geoffgarside/ber v1.1.0 h1:qTmFG4jJbwiSzSXoNJeHcOprVzZ8Ulde2Rrrifu5U9w= +github.com/geoffgarside/ber v1.1.0/go.mod h1:jVPKeCbj6MvQZhwLYsGwaGI52oUorHoHKNecGT85ZCc= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 h1:BP4M0CvQ4S3TGls2FvczZtj5Re/2ZzkV9VwqPHH/3Bo= github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0= github.com/go-ldap/ldap/v3 v3.4.13 h1:+x1nG9h+MZN7h/lUi5Q3UZ0fJ1GyDQYbPvbuH38baDQ= @@ -17,6 +19,8 @@ github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArs github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hirochachacha/go-smb2 v1.1.0 h1:b6hs9qKIql9eVXAiN0M2wSFY5xnhbHAQoCwRKbaRTZI= +github.com/hirochachacha/go-smb2 v1.1.0/go.mod h1:8F1A4d5EZzrGu5R7PU163UcMRDJQl4FtcxjBfsY8TZE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -36,6 +40,7 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= @@ -48,6 +53,7 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= @@ -66,6 +72,7 @@ golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= diff --git a/backend/milestone_backup.go b/backend/milestone_backup.go new file mode 100644 index 0000000..352b7a6 --- /dev/null +++ b/backend/milestone_backup.go @@ -0,0 +1,1191 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "log" + "net" + "net/http" + "os" + pathpkg "path" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" + + "github.com/hirochachacha/go-smb2" + "github.com/jackc/pgx/v5" + "golang.org/x/text/encoding/unicode" + "golang.org/x/text/transform" +) + +const ( + backupLogListTailBytes int64 = 512 * 1024 + backupLogDetailTailBytes int64 = 1024 * 1024 + backupLogMaxFiles = 200 + backupLogTailLineCount = 240 + backupSMBTimeout = 15 * time.Second +) + +var errInvalidMilestoneBackupLogName = errors.New("ungueltiger Backup-Protokolldateiname") + +type milestoneBackupLogFile struct { + Name string `json:"name"` + Size int64 `json:"size"` + ModifiedAt time.Time `json:"modifiedAt"` + Summary milestoneBackupLogSummary `json:"summary"` +} + +type milestoneBackupLogDetail struct { + milestoneBackupLogFile + TailLines []string `json:"tailLines"` +} + +type milestoneBackupLogSummary struct { + Status string `json:"status"` + StartedAt string `json:"startedAt"` + EndedAt string `json:"endedAt"` + Duration string `json:"duration"` + TotalFiles string `json:"totalFiles"` + CopiedFiles string `json:"copiedFiles"` + FailedCount int `json:"failedCount"` + Rows []milestoneBackupLogSummaryRow `json:"rows"` +} + +type milestoneBackupLogSummaryRow struct { + Key string `json:"key"` + Label string `json:"label"` + Total string `json:"total"` + Copied string `json:"copied"` + Skipped string `json:"skipped"` + Mismatch string `json:"mismatch"` + Failed string `json:"failed"` + Extras string `json:"extras"` +} + +type milestoneBackupSettings struct { + NasHost string + NasShare string + NasUsername string + NasPassword string + LogRootPath string +} + +type milestoneBackupFolderRoot struct { + Label string `json:"label"` + Path string `json:"path"` +} + +type milestoneBackupFolderItem struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` +} + +type milestoneBackupFolderBrowseRequest struct { + NasHost string `json:"nasHost"` + NasShare string `json:"nasShare"` + NasUsername string `json:"nasUsername"` + NasPassword string `json:"nasPassword"` + Path string `json:"path"` +} + +func (s *Server) handleListMilestoneBackupLogs(w http.ResponseWriter, r *http.Request) { + settings, err := s.getMilestoneBackupSettings(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusBadRequest, "Backup-Protokollpfad ist nicht konfiguriert") + return + } + + if err != nil { + log.Printf("Milestone backup log path load failed: %v", err) + writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht geladen werden") + return + } + + rootPath := settings.effectiveLogRootPath() + if strings.TrimSpace(rootPath) == "" { + writeError(w, http.StatusBadRequest, "Backup-Protokollpfad ist nicht konfiguriert") + return + } + + fileName := strings.TrimSpace(r.URL.Query().Get("file")) + if fileName != "" { + detail, err := readMilestoneBackupLogDetail(r.Context(), settings, rootPath, fileName) + if err != nil { + log.Printf("Milestone backup log detail failed: file=%q error=%v", fileName, err) + writeMilestoneBackupLogError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "rootPath": rootPath, + "log": detail, + }) + return + } + + files, err := listMilestoneBackupLogFiles(r.Context(), settings, rootPath) + if err != nil { + log.Printf("Milestone backup log list failed: root=%q error=%v", rootPath, err) + writeMilestoneBackupLogError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "rootPath": rootPath, + "logs": files, + }) +} + +func (s *Server) handleListMilestoneBackupFolders(w http.ResponseWriter, r *http.Request) { + settings, err := s.getMilestoneBackupSettings(r.Context()) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusBadRequest, "Backup-NAS oder Protokollordner ist nicht konfiguriert") + return + } + + if err != nil { + log.Printf("Milestone backup settings load failed: %v", err) + writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht geladen werden") + return + } + + rootPath := settings.effectiveBrowseRootPath() + if rootPath == "" { + writeError(w, http.StatusBadRequest, "Backup-NAS oder Protokollordner ist nicht konfiguriert") + return + } + + requestedPath := strings.TrimSpace(r.URL.Query().Get("path")) + currentPath := requestedPath + if currentPath == "" { + currentPath = rootPath + } + + folders, err := listMilestoneBackupFolders(r.Context(), settings, rootPath, currentPath) + if err != nil { + log.Printf("Milestone backup folder list failed: path=%q error=%v", currentPath, err) + writeMilestoneBackupLogError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "path": currentPath, + "roots": []milestoneBackupFolderRoot{ + {Label: "Backup", Path: rootPath}, + }, + "folders": folders, + }) +} + +func (s *Server) handleBrowseMilestoneBackupFolders(w http.ResponseWriter, r *http.Request) { + var input milestoneBackupFolderBrowseRequest + + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + settings, err := s.getMilestoneBackupBrowseSettings(r.Context(), input) + if err != nil { + log.Printf("Milestone backup browse settings load failed: %v", err) + writeError(w, http.StatusInternalServerError, "Backup-Einstellungen konnten nicht geladen werden") + return + } + + if settings.NasHost == "" { + writeError(w, http.StatusBadRequest, "NAS-IP oder Hostname ist erforderlich") + return + } + + browseCtx, cancelBrowse := context.WithTimeout(r.Context(), backupSMBTimeout) + defer cancelBrowse() + + requestedPath := settings.canonicalDisplayPath(input.Path) + if settings.NasShare == "" { + roots, err := listMilestoneBackupShareRoots(browseCtx, settings) + if err != nil { + log.Printf("Milestone backup share list failed: host=%q error=%v", settings.NasHost, err) + writeMilestoneBackupLogError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "path": settings.uncHostPath(), + "roots": roots, + "folders": []milestoneBackupFolderItem{}, + }) + return + } + + rootPath := settings.uncRootPath() + currentPath := requestedPath + if currentPath == "" { + currentPath = rootPath + } + + folders, err := listMilestoneBackupFoldersSMB(browseCtx, settings, rootPath, currentPath) + if err != nil { + if errors.Is(err, os.ErrPermission) && normalizeBackupDisplayPath(currentPath) == normalizeBackupDisplayPath(rootPath) { + writeJSON(w, http.StatusOK, map[string]any{ + "path": currentPath, + "roots": []milestoneBackupFolderRoot{ + {Label: settings.NasShare, Path: rootPath}, + }, + "folders": []milestoneBackupFolderItem{}, + "warning": "Die NAS-Freigabe wurde gefunden, aber der Ordner kann nicht aufgelistet werden. Prüfe die Leserechte oder trage den Unterordner manuell ein.", + }) + return + } + + log.Printf("Milestone backup folder browse failed: path=%q error=%v", currentPath, err) + writeMilestoneBackupLogError(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "path": currentPath, + "roots": []milestoneBackupFolderRoot{ + {Label: settings.NasShare, Path: rootPath}, + }, + "folders": folders, + }) +} + +func (s *Server) getMilestoneBackupBrowseSettings( + ctx context.Context, + input milestoneBackupFolderBrowseRequest, +) (milestoneBackupSettings, error) { + savedSettings, err := s.getMilestoneBackupSettings(ctx) + if err != nil && !errors.Is(err, pgx.ErrNoRows) { + return milestoneBackupSettings{}, err + } + + hasSavedSettings := err == nil + settings := milestoneBackupSettings{ + NasHost: normalizeBackupNasHost(input.NasHost), + NasShare: normalizeBackupNasShare(input.NasShare), + NasUsername: strings.TrimSpace(input.NasUsername), + NasPassword: strings.TrimSpace(input.NasPassword), + LogRootPath: strings.TrimSpace(input.Path), + } + + pathHost, pathShare := backupUNCPathHostAndShare(input.Path) + if settings.NasHost == "" { + settings.NasHost = pathHost + } + if settings.NasShare == "" { + settings.NasShare = pathShare + } + + if settings.NasHost == "" && hasSavedSettings { + settings.NasHost = savedSettings.NasHost + } + + if settings.NasUsername == "" && hasSavedSettings && settings.NasHost == savedSettings.NasHost { + settings.NasUsername = savedSettings.NasUsername + } + + if settings.NasPassword == "" && + hasSavedSettings && + settings.NasHost == savedSettings.NasHost && + settings.NasUsername == savedSettings.NasUsername { + settings.NasPassword = savedSettings.NasPassword + } + + return settings, nil +} + +func (s *Server) getMilestoneBackupSettings(ctx context.Context) (milestoneBackupSettings, error) { + var settings milestoneBackupSettings + + err := s.db.QueryRow( + ctx, + ` + SELECT + COALESCE(backup_nas_host, ''), + COALESCE(backup_nas_share, ''), + COALESCE(backup_nas_username, ''), + COALESCE(pgp_sym_decrypt(backup_nas_password_encrypted, $1), ''), + COALESCE(backup_log_root_path, '') + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + s.milestoneEncryptionKey(), + ).Scan( + &settings.NasHost, + &settings.NasShare, + &settings.NasUsername, + &settings.NasPassword, + &settings.LogRootPath, + ) + + settings.NasHost = normalizeBackupNasHost(settings.NasHost) + settings.NasShare = normalizeBackupNasShare(settings.NasShare) + settings.NasUsername = strings.TrimSpace(settings.NasUsername) + settings.NasPassword = strings.TrimSpace(settings.NasPassword) + settings.LogRootPath = strings.TrimSpace(settings.LogRootPath) + + return settings, err +} + +func writeMilestoneBackupLogError(w http.ResponseWriter, err error) { + if errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusNotFound, "Backup-Protokoll wurde nicht gefunden") + return + } + + if errors.Is(err, os.ErrPermission) { + writeError(w, http.StatusForbidden, "Backup-Protokollpfad kann nicht gelesen werden. Prüfe die NAS-Freigabe, den Benutzer und die Leserechte.") + return + } + + if errors.Is(err, context.DeadlineExceeded) { + writeError(w, http.StatusGatewayTimeout, "NAS hat nicht rechtzeitig geantwortet. Prüfe Hostname, Benutzername, Domäne und Netzwerkverbindung.") + return + } + + if errors.Is(err, context.Canceled) { + writeError(w, http.StatusGatewayTimeout, "NAS-Anfrage wurde abgebrochen.") + return + } + + if errors.Is(err, errInvalidMilestoneBackupLogName) { + writeError(w, http.StatusBadRequest, "Backup-Protokolldateiname ist ungültig") + return + } + + writeError(w, http.StatusInternalServerError, "Backup-Protokolle konnten nicht geladen werden") +} + +func (settings milestoneBackupSettings) hasNAS() bool { + return settings.NasHost != "" && settings.NasShare != "" +} + +func (settings milestoneBackupSettings) uncRootPath() string { + if !settings.hasNAS() { + return "" + } + + return `\\` + settings.NasHost + `\` + settings.NasShare +} + +func (settings milestoneBackupSettings) uncHostPath() string { + if settings.NasHost == "" { + return "" + } + + return `\\` + settings.NasHost +} + +func (settings milestoneBackupSettings) effectiveBrowseRootPath() string { + if settings.hasNAS() { + return settings.uncRootPath() + } + + return strings.TrimSpace(settings.LogRootPath) +} + +func (settings milestoneBackupSettings) effectiveLogRootPath() string { + if strings.TrimSpace(settings.LogRootPath) != "" { + return strings.TrimSpace(settings.LogRootPath) + } + + return settings.effectiveBrowseRootPath() +} + +func listMilestoneBackupLogFiles(ctx context.Context, settings milestoneBackupSettings, rootPath string) ([]milestoneBackupLogFile, error) { + if settings.hasNAS() { + return listMilestoneBackupLogFilesSMB(ctx, settings, rootPath) + } + + entries, err := os.ReadDir(rootPath) + if err != nil { + return nil, err + } + + files := make([]milestoneBackupLogFile, 0) + + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") { + continue + } + + info, err := entry.Info() + if err != nil { + return nil, err + } + + path := filepath.Join(rootPath, entry.Name()) + text, err := readTextFileTail(path, backupLogListTailBytes) + if err != nil { + return nil, err + } + + files = append(files, milestoneBackupLogFile{ + Name: entry.Name(), + Size: info.Size(), + ModifiedAt: info.ModTime(), + Summary: parseMilestoneBackupLogSummary(text), + }) + } + + sort.Slice(files, func(left, right int) bool { + return files[left].ModifiedAt.After(files[right].ModifiedAt) + }) + + if len(files) > backupLogMaxFiles { + files = files[:backupLogMaxFiles] + } + + return files, nil +} + +func listMilestoneBackupLogFilesSMB(ctx context.Context, settings milestoneBackupSettings, rootPath string) ([]milestoneBackupLogFile, error) { + share, cleanup, err := openMilestoneBackupSMBShare(ctx, settings) + if err != nil { + return nil, err + } + defer cleanup() + + relativeRoot, err := settings.relativeSMBPath(rootPath) + if err != nil { + return nil, err + } + + entries, err := share.ReadDir(relativeRoot) + if err != nil { + return nil, err + } + + files := make([]milestoneBackupLogFile, 0) + for _, entry := range entries { + if entry.IsDir() || !strings.EqualFold(filepath.Ext(entry.Name()), ".log") { + continue + } + + relativeFilePath := joinSMBRelativePath(relativeRoot, entry.Name()) + text, err := readSMBTextFileTail(share, relativeFilePath, backupLogListTailBytes) + if err != nil { + return nil, err + } + + files = append(files, milestoneBackupLogFile{ + Name: entry.Name(), + Size: entry.Size(), + ModifiedAt: entry.ModTime(), + Summary: parseMilestoneBackupLogSummary(text), + }) + } + + sort.Slice(files, func(left, right int) bool { + return files[left].ModifiedAt.After(files[right].ModifiedAt) + }) + + if len(files) > backupLogMaxFiles { + files = files[:backupLogMaxFiles] + } + + return files, nil +} + +func readMilestoneBackupLogDetail(ctx context.Context, settings milestoneBackupSettings, rootPath string, fileName string) (milestoneBackupLogDetail, error) { + if settings.hasNAS() { + return readMilestoneBackupLogDetailSMB(ctx, settings, rootPath, fileName) + } + + path, err := resolveMilestoneBackupLogPath(rootPath, fileName) + if err != nil { + return milestoneBackupLogDetail{}, err + } + + info, err := os.Stat(path) + if err != nil { + return milestoneBackupLogDetail{}, err + } + + if info.IsDir() { + return milestoneBackupLogDetail{}, os.ErrNotExist + } + + text, err := readTextFileTail(path, backupLogDetailTailBytes) + if err != nil { + return milestoneBackupLogDetail{}, err + } + + return milestoneBackupLogDetail{ + milestoneBackupLogFile: milestoneBackupLogFile{ + Name: filepath.Base(path), + Size: info.Size(), + ModifiedAt: info.ModTime(), + Summary: parseMilestoneBackupLogSummary(text), + }, + TailLines: tailNonEmptyLines(text, backupLogTailLineCount), + }, nil +} + +func readMilestoneBackupLogDetailSMB(ctx context.Context, settings milestoneBackupSettings, rootPath string, fileName string) (milestoneBackupLogDetail, error) { + if err := validateMilestoneBackupLogFileName(fileName); err != nil { + return milestoneBackupLogDetail{}, err + } + + share, cleanup, err := openMilestoneBackupSMBShare(ctx, settings) + if err != nil { + return milestoneBackupLogDetail{}, err + } + defer cleanup() + + relativeRoot, err := settings.relativeSMBPath(rootPath) + if err != nil { + return milestoneBackupLogDetail{}, err + } + + relativeFilePath := joinSMBRelativePath(relativeRoot, fileName) + info, err := share.Stat(relativeFilePath) + if err != nil { + return milestoneBackupLogDetail{}, err + } + + if info.IsDir() { + return milestoneBackupLogDetail{}, os.ErrNotExist + } + + text, err := readSMBTextFileTail(share, relativeFilePath, backupLogDetailTailBytes) + if err != nil { + return milestoneBackupLogDetail{}, err + } + + return milestoneBackupLogDetail{ + milestoneBackupLogFile: milestoneBackupLogFile{ + Name: filepath.Base(fileName), + Size: info.Size(), + ModifiedAt: info.ModTime(), + Summary: parseMilestoneBackupLogSummary(text), + }, + TailLines: tailNonEmptyLines(text, backupLogTailLineCount), + }, nil +} + +func resolveMilestoneBackupLogPath(rootPath string, fileName string) (string, error) { + if err := validateMilestoneBackupLogFileName(fileName); err != nil { + return "", err + } + + cleanName := strings.TrimSpace(fileName) + + return filepath.Join(rootPath, cleanName), nil +} + +func validateMilestoneBackupLogFileName(fileName string) error { + cleanName := strings.TrimSpace(fileName) + if cleanName == "" || + cleanName != filepath.Base(cleanName) || + strings.Contains(cleanName, "/") || + strings.Contains(cleanName, `\`) || + !strings.EqualFold(filepath.Ext(cleanName), ".log") { + return errInvalidMilestoneBackupLogName + } + + return nil +} + +func readTextFileTail(path string, maxBytes int64) (string, error) { + file, err := os.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return "", err + } + + return readTextReadSeekerTail(file, info.Size(), maxBytes) +} + +func readSMBTextFileTail(share *smb2.Share, path string, maxBytes int64) (string, error) { + file, err := share.Open(path) + if err != nil { + return "", err + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + return "", err + } + + return readTextReadSeekerTail(file, info.Size(), maxBytes) +} + +func readTextReadSeekerTail(file interface { + io.Reader + io.ReaderAt + io.Seeker +}, size int64, maxBytes int64) (string, error) { + start := int64(0) + if size > maxBytes { + start = size - maxBytes + } + + reader := io.NewSectionReader(file, start, size-start) + data, err := io.ReadAll(reader) + if err != nil { + return "", err + } + + if start > 0 { + if index := bytes.IndexByte(data, '\n'); index >= 0 && index+1 < len(data) { + data = data[index+1:] + } + } + + return decodeMilestoneBackupLogText(data), nil +} + +func listMilestoneBackupFolders(ctx context.Context, settings milestoneBackupSettings, rootPath string, currentPath string) ([]milestoneBackupFolderItem, error) { + if settings.hasNAS() { + return listMilestoneBackupFoldersSMB(ctx, settings, rootPath, currentPath) + } + + return listMilestoneBackupFoldersLocal(rootPath, currentPath) +} + +func listMilestoneBackupShareRoots(ctx context.Context, settings milestoneBackupSettings) ([]milestoneBackupFolderRoot, error) { + session, cleanup, err := openMilestoneBackupSMBSession(ctx, settings) + if err != nil { + return nil, err + } + defer cleanup() + + shareNames, err := session.ListSharenames() + if err != nil { + return nil, err + } + + roots := make([]milestoneBackupFolderRoot, 0, len(shareNames)) + for _, shareName := range shareNames { + shareName = normalizeBackupNasShare(shareName) + if shareName == "" || strings.EqualFold(shareName, "IPC$") { + continue + } + + roots = append(roots, milestoneBackupFolderRoot{ + Label: shareName, + Path: `\\` + settings.NasHost + `\` + shareName, + }) + } + + sort.Slice(roots, func(left, right int) bool { + return strings.ToLower(roots[left].Label) < strings.ToLower(roots[right].Label) + }) + + return roots, nil +} + +func listMilestoneBackupFoldersLocal(rootPath string, currentPath string) ([]milestoneBackupFolderItem, error) { + if !pathIsInsideBackupRoot(rootPath, currentPath) { + return nil, errInvalidMilestoneBackupLogName + } + + entries, err := os.ReadDir(currentPath) + if err != nil { + return nil, err + } + + folders := make([]milestoneBackupFolderItem, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + path := filepath.Join(currentPath, entry.Name()) + folders = append(folders, milestoneBackupFolderItem{ + Name: entry.Name(), + DisplayName: entry.Name(), + Path: path, + HasChildren: directoryHasChildFolders(path), + }) + } + + sort.Slice(folders, func(left, right int) bool { + return strings.ToLower(folders[left].Name) < strings.ToLower(folders[right].Name) + }) + + return folders, nil +} + +func listMilestoneBackupFoldersSMB(ctx context.Context, settings milestoneBackupSettings, rootPath string, currentPath string) ([]milestoneBackupFolderItem, error) { + if !pathIsInsideBackupRoot(rootPath, currentPath) { + return nil, errInvalidMilestoneBackupLogName + } + + share, cleanup, err := openMilestoneBackupSMBShare(ctx, settings) + if err != nil { + return nil, err + } + defer cleanup() + + relativeCurrentPath, err := settings.relativeSMBPath(currentPath) + if err != nil { + return nil, err + } + + entries, err := share.ReadDir(relativeCurrentPath) + if err != nil { + return nil, err + } + + folders := make([]milestoneBackupFolderItem, 0) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + relativePath := joinSMBRelativePath(relativeCurrentPath, entry.Name()) + folders = append(folders, milestoneBackupFolderItem{ + Name: entry.Name(), + DisplayName: entry.Name(), + Path: settings.uncPathFromRelative(relativePath), + HasChildren: smbDirectoryHasChildFolders(share, relativePath), + }) + } + + sort.Slice(folders, func(left, right int) bool { + return strings.ToLower(folders[left].Name) < strings.ToLower(folders[right].Name) + }) + + return folders, nil +} + +func pathIsInsideBackupRoot(rootPath string, currentPath string) bool { + root := normalizeBackupDisplayPath(rootPath) + current := normalizeBackupDisplayPath(currentPath) + + return root != "" && (current == root || strings.HasPrefix(current, root+`\`)) +} + +func normalizeBackupDisplayPath(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "/", `\`) + value = strings.TrimRight(value, `\`) + + if strings.HasPrefix(value, `\\`) { + value = `\\` + strings.TrimPrefix(value, `\\`) + } + + return strings.ToLower(value) +} + +func (settings milestoneBackupSettings) canonicalDisplayPath(value string) string { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "/", `\`) + value = strings.TrimRight(value, `\`) + + if value == "" { + return "" + } + + if strings.HasPrefix(value, `\\`) { + return `\\` + strings.TrimPrefix(value, `\\`) + } + + host := strings.TrimSpace(settings.NasHost) + if host != "" && strings.HasPrefix(strings.ToLower(value), strings.ToLower(host)+`\`) { + return `\\` + value + } + + return value +} + +func directoryHasChildFolders(path string) bool { + entries, err := os.ReadDir(path) + if err != nil { + return false + } + + for _, entry := range entries { + if entry.IsDir() { + return true + } + } + + return false +} + +func smbDirectoryHasChildFolders(share *smb2.Share, path string) bool { + entries, err := share.ReadDir(path) + if err != nil { + return false + } + + for _, entry := range entries { + if entry.IsDir() { + return true + } + } + + return false +} + +func openMilestoneBackupSMBSession(ctx context.Context, settings milestoneBackupSettings) (*smb2.Session, func(), error) { + dialer := net.Dialer{Timeout: 10 * time.Second} + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort(settings.NasHost, "445")) + if err != nil { + return nil, func() {}, err + } + + domain, username := splitBackupNasUsername(settings.NasUsername) + smbDialer := smb2.Dialer{ + Initiator: &smb2.NTLMInitiator{ + User: username, + Password: settings.NasPassword, + Domain: domain, + }, + } + + session, err := smbDialer.DialContext(ctx, conn) + if err != nil { + conn.Close() + return nil, func() {}, err + } + + cleanup := func() { + session.Logoff() + conn.Close() + } + + return session, cleanup, nil +} + +func openMilestoneBackupSMBShare(ctx context.Context, settings milestoneBackupSettings) (*smb2.Share, func(), error) { + session, cleanupSession, err := openMilestoneBackupSMBSession(ctx, settings) + if err != nil { + return nil, func() {}, err + } + + share, err := session.Mount(settings.NasShare) + if err != nil { + cleanupSession() + return nil, func() {}, err + } + share = share.WithContext(ctx) + + cleanup := func() { + share.Umount() + cleanupSession() + } + + return share, cleanup, nil +} + +func splitBackupNasUsername(value string) (string, string) { + value = strings.TrimSpace(value) + if value == "" { + return "", "" + } + + if index := strings.Index(value, `\`); index > 0 { + return value[:index], value[index+1:] + } + + if index := strings.Index(value, `/`); index > 0 { + return value[:index], value[index+1:] + } + + return "", value +} + +func backupUNCPathHostAndShare(value string) (string, string) { + value = strings.TrimSpace(value) + value = strings.ReplaceAll(value, "/", `\`) + value = strings.TrimPrefix(value, `\\`) + value = strings.Trim(value, `\`) + + parts := strings.Split(value, `\`) + if len(parts) < 2 { + return normalizeBackupNasHost(value), "" + } + + return normalizeBackupNasHost(parts[0]), normalizeBackupNasShare(parts[1]) +} + +func (settings milestoneBackupSettings) relativeSMBPath(displayPath string) (string, error) { + base := normalizeBackupDisplayPath(settings.uncRootPath()) + current := normalizeBackupDisplayPath(displayPath) + + if current == "" || current == base { + return ".", nil + } + + if !strings.HasPrefix(current, base+`\`) { + return "", errInvalidMilestoneBackupLogName + } + + relativePath := strings.TrimPrefix(current, base+`\`) + relativePath = strings.ReplaceAll(relativePath, `\`, "/") + relativePath = pathpkg.Clean(relativePath) + + if relativePath == "." || relativePath == "/" { + return ".", nil + } + + if relativePath == ".." || strings.HasPrefix(relativePath, "../") { + return "", errInvalidMilestoneBackupLogName + } + + return relativePath, nil +} + +func (settings milestoneBackupSettings) uncPathFromRelative(relativePath string) string { + cleanRelativePath := strings.TrimSpace(relativePath) + cleanRelativePath = strings.Trim(cleanRelativePath, `/\`) + cleanRelativePath = strings.ReplaceAll(cleanRelativePath, "/", `\`) + + if cleanRelativePath == "" || cleanRelativePath == "." { + return settings.uncRootPath() + } + + return strings.TrimRight(settings.uncRootPath(), `\`) + `\` + cleanRelativePath +} + +func joinSMBRelativePath(basePath string, childName string) string { + basePath = strings.TrimSpace(basePath) + childName = strings.Trim(childName, `/\`) + + if basePath == "" || basePath == "." { + return childName + } + + return strings.TrimRight(basePath, `/\`) + "/" + childName +} + +func decodeMilestoneBackupLogText(data []byte) string { + if len(data) >= 2 { + if data[0] == 0xff && data[1] == 0xfe { + return decodeUTF16(data, unicode.LittleEndian) + } + + if data[0] == 0xfe && data[1] == 0xff { + return decodeUTF16(data, unicode.BigEndian) + } + } + + if looksLikeUTF16LE(data) { + return decodeUTF16(data, unicode.LittleEndian) + } + + if looksLikeUTF16BE(data) { + return decodeUTF16(data, unicode.BigEndian) + } + + if utf8.Valid(data) { + return string(data) + } + + return string(bytes.ToValidUTF8(data, []byte("?"))) +} + +func decodeUTF16(data []byte, endianness unicode.Endianness) string { + decoder := unicode.UTF16(endianness, unicode.UseBOM).NewDecoder() + decoded, _, err := transform.Bytes(decoder, data) + if err != nil { + return string(bytes.ToValidUTF8(data, []byte("?"))) + } + + return string(decoded) +} + +func looksLikeUTF16LE(data []byte) bool { + return countZeroBytesAtParity(data, 1) > len(data)/8 +} + +func looksLikeUTF16BE(data []byte) bool { + return countZeroBytesAtParity(data, 0) > len(data)/8 +} + +func countZeroBytesAtParity(data []byte, parity int) int { + count := 0 + limit := len(data) + if limit > 4096 { + limit = 4096 + } + + for index := parity; index < limit; index += 2 { + if data[index] == 0 { + count++ + } + } + + return count +} + +var backupLogSummaryLinePattern = regexp.MustCompile(`^\s*([^:]{1,48})\s*:\s*(.+?)\s*$`) +var multiSpacePattern = regexp.MustCompile(`\s{2,}`) +var leadingIntegerPattern = regexp.MustCompile(`^\D*([0-9][0-9.,]*)`) + +func parseMilestoneBackupLogSummary(text string) milestoneBackupLogSummary { + summary := milestoneBackupLogSummary{ + Status: "unknown", + Rows: []milestoneBackupLogSummaryRow{}, + } + + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + startIndex := len(lines) - 600 + if startIndex < 0 { + startIndex = 0 + } + + for _, line := range lines[startIndex:] { + trimmedLine := strings.TrimSpace(line) + if trimmedLine == "" { + continue + } + + lowerLine := strings.ToLower(trimmedLine) + if strings.Contains(lowerLine, "started") || strings.Contains(lowerLine, "gestartet") { + if value := valueAfterColon(trimmedLine); value != "" { + summary.StartedAt = value + } + } + + if strings.Contains(lowerLine, "ended") || strings.Contains(lowerLine, "beendet") { + if value := valueAfterColon(trimmedLine); value != "" { + summary.EndedAt = value + } + } + + matches := backupLogSummaryLinePattern.FindStringSubmatch(line) + if len(matches) != 3 { + continue + } + + key, label := normalizeBackupLogSummaryLabel(matches[1]) + if key == "" { + continue + } + + cells := parseBackupLogSummaryCells(matches[2]) + if len(cells) < 6 { + continue + } + + row := milestoneBackupLogSummaryRow{ + Key: key, + Label: label, + Total: cells[0], + Copied: cells[1], + Skipped: cells[2], + Mismatch: cells[3], + Failed: cells[4], + Extras: cells[5], + } + summary.Rows = append(summary.Rows, row) + + if key == "files" { + summary.TotalFiles = row.Total + summary.CopiedFiles = row.Copied + summary.FailedCount += parseBackupLogInteger(row.Failed) + } + + if key == "dirs" { + summary.FailedCount += parseBackupLogInteger(row.Failed) + } + + if key == "times" { + summary.Duration = row.Total + } + } + + if summary.FailedCount > 0 { + summary.Status = "failed" + } else if len(summary.Rows) > 0 { + summary.Status = "success" + } + + return summary +} + +func valueAfterColon(value string) string { + index := strings.Index(value, ":") + if index < 0 || index+1 >= len(value) { + return "" + } + + return strings.TrimSpace(value[index+1:]) +} + +func normalizeBackupLogSummaryLabel(value string) (string, string) { + normalized := strings.ToLower(strings.TrimSpace(value)) + normalized = strings.Trim(normalized, ".") + + switch normalized { + case "dirs", "directories", "verzeichnisse", "ordner": + return "dirs", "Verzeichnisse" + case "files", "dateien": + return "files", "Dateien" + case "bytes", "byte": + return "bytes", "Bytes" + case "times", "zeiten", "time": + return "times", "Dauer" + } + + return "", "" +} + +func parseBackupLogSummaryCells(value string) []string { + cells := multiSpacePattern.Split(strings.TrimSpace(value), -1) + if len(cells) >= 6 { + return cells[:6] + } + + fields := strings.Fields(value) + if len(fields) >= 6 { + return fields[:6] + } + + return cells +} + +func parseBackupLogInteger(value string) int { + match := leadingIntegerPattern.FindStringSubmatch(strings.TrimSpace(value)) + if len(match) != 2 { + return 0 + } + + cleanValue := strings.NewReplacer(".", "", ",", "", " ", "").Replace(match[1]) + number, err := strconv.Atoi(cleanValue) + if err != nil { + return 0 + } + + return number +} + +func tailNonEmptyLines(text string, maxLines int) []string { + lines := strings.Split(strings.ReplaceAll(text, "\r\n", "\n"), "\n") + cleanLines := make([]string, 0, len(lines)) + + for _, line := range lines { + cleanLines = append(cleanLines, strings.TrimRight(line, "\r")) + } + + for len(cleanLines) > 0 && strings.TrimSpace(cleanLines[len(cleanLines)-1]) == "" { + cleanLines = cleanLines[:len(cleanLines)-1] + } + + if len(cleanLines) > maxLines { + cleanLines = cleanLines[len(cleanLines)-maxLines:] + } + + return cleanLines +} diff --git a/backend/operations_milestone_setup.go b/backend/operations_milestone_setup.go index d2bd19a..ed2038c 100644 --- a/backend/operations_milestone_setup.go +++ b/backend/operations_milestone_setup.go @@ -181,7 +181,7 @@ func (s *Server) handleOperationMilestoneSetup(w http.ResponseWriter, r *http.Re Type: "step", Step: "roleUser", Status: "current", - Message: "Auswertungsrolle und AD-Benutzer werden geprueft.", + Message: "Einsatzrolle und Auswerter-Benutzer werden geprueft.", }) targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input) if err != nil { @@ -824,86 +824,43 @@ func (s *Server) ensureOperationMilestoneRoleUser( return nil, nil } - roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation) - if roleName == "" { - return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber) + operationRoleName := operationMilestoneRoleName(operation) + if operationRoleName == "" { + return nil, errors.New("Einsatznummer fehlt, Milestone-Rolle konnte nicht angelegt werden") } - sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName) - if err != nil { - return nil, err - } - if sourceRole == nil { - operationRoleName := operationMilestoneRoleName(operation) - if operationRoleName == "" { - return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Einsatznummer fehlt", roleName) - } - - role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName) - if err != nil { - return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Rolle %q konnte nicht angelegt werden: %w", roleName, operationRoleName, err) - } - - role, err = getOperationMilestoneRole(ctx, config, role.ID) - if err != nil { - return nil, err - } - - if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { - roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID) - if listErr != nil { - roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) - } - - if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil { - if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil { - return nil, err - } - roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser) - } - role.Data["users"] = roleUsers - } - - return role, nil - } - - targetUserName := strings.TrimSpace(laptopDevice.ComputerName) - if targetUserName == "" { - targetUserName = roleName - } - - role, err := getOperationMilestoneRole(ctx, config, sourceRole.ID) + role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName) if err != nil { return nil, err } - sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID) + role, err = getOperationMilestoneRole(ctx, config, role.ID) if err != nil { return nil, err } - candidateUsers := sourceUsers - if hasOperationMilestoneUserSelection(input) { - allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config) - if err != nil { - return nil, err - } - candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...) - } - usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input) - if len(usersToAssign) == 0 { - if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { - usersToAssign = []map[string]any{selectedUser} - } else { - return nil, fmt.Errorf("AD-Benutzer %q wurde unter /roles/%s/users nicht gefunden", targetUserName, sourceRole.ID) - } - } - - roleUsers := sourceUsers - if len(roleUsers) == 0 { + roleUsers, err := listOperationMilestoneRoleUsers(ctx, config, role.ID) + if err != nil { roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) } + usersToAssign, targetUserName, err := s.operationMilestoneRoleUsersForLaptop( + ctx, + config, + *laptopDevice, + operation, + input, + ) + if err != nil { + return nil, err + } + if len(usersToAssign) == 0 { + if targetUserName == "" { + targetUserName = operationMilestoneEvaluationRoleName(*laptopDevice, operation) + } + return nil, fmt.Errorf("AD-Benutzer %q wurde nicht gefunden", targetUserName) + } + usersToAdd := []map[string]any{} for _, targetUser := range usersToAssign { if milestoneItemID(targetUser) == "" { @@ -928,6 +885,58 @@ func (s *Server) ensureOperationMilestoneRoleUser( return role, nil } +func (s *Server) operationMilestoneRoleUsersForLaptop( + ctx context.Context, + config milestoneRuntimeConfig, + laptopDevice operationMilestoneSetupDevice, + operation Operation, + input operationMilestoneSetupRequest, +) ([]map[string]any, string, error) { + if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { + return []map[string]any{selectedUser}, strings.TrimSpace(input.MilestoneUserName), nil + } + + targetUserName := operationMilestoneEvaluationUserName(laptopDevice, operation) + if targetUserName != "" { + user, err := s.findOperationMilestoneActiveDirectoryUser(ctx, targetUserName) + if err != nil { + return nil, targetUserName, err + } + if user != nil { + return []map[string]any{user}, targetUserName, nil + } + } + + evaluationRoleName := operationMilestoneEvaluationRoleName(laptopDevice, operation) + candidateUsers := []map[string]any{} + if evaluationRoleName != "" { + sourceRole, err := findOperationMilestoneRoleByName(ctx, config, evaluationRoleName) + if err != nil { + return nil, targetUserName, err + } + if sourceRole != nil { + sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID) + if err != nil { + return nil, targetUserName, err + } + candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, sourceUsers...) + } + } + + allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config) + if err != nil { + return nil, targetUserName, err + } + candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...) + + usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input) + if len(usersToAssign) > 0 { + return usersToAssign, targetUserName, nil + } + + return nil, targetUserName, nil +} + func selectOperationMilestoneRoleUsersForAssignment( users []map[string]any, preferredUserName string, @@ -941,6 +950,10 @@ func selectOperationMilestoneRoleUsersForAssignment( return []map[string]any{targetUser} } + if strings.TrimSpace(preferredUserName) != "" { + return nil + } + usersWithID := make([]map[string]any, 0, len(users)) for _, user := range users { if milestoneItemID(user) != "" { @@ -1116,6 +1129,146 @@ func operationMilestoneEvaluationRoleName( return "" } +func operationMilestoneEvaluationUserName( + laptopDevice operationMilestoneSetupDevice, + operation Operation, +) string { + candidates := []string{ + laptopDevice.ComputerName, + laptopDevice.InventoryNumber, + laptopDevice.Model, + operation.Laptop, + } + + for _, candidate := range candidates { + match := operationMilestoneEvaluationRolePattern.FindStringSubmatch(candidate) + if len(match) == 2 { + return "auswertung" + match[1] + } + } + + return "" +} + +func (s *Server) findOperationMilestoneActiveDirectoryUser( + ctx context.Context, + targetUserName string, +) (map[string]any, error) { + target := normalizeOperationMilestoneAccountName(targetUserName) + if target == "" { + return nil, nil + } + + credentials, err := s.getActiveDirectoryCredentials(ctx) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + if strings.TrimSpace(credentials.ServerURL) == "" || + strings.TrimSpace(credentials.BaseDN) == "" || + strings.TrimSpace(credentials.BindUsername) == "" || + strings.TrimSpace(credentials.BindPassword) == "" { + return nil, nil + } + + users, err := listActiveDirectoryUsers(ctx, credentials) + if err != nil { + return nil, err + } + + for _, user := range users { + if operationMilestoneActiveDirectoryUserMatches(user, target) { + return operationMilestoneUserFromActiveDirectoryUser(user), nil + } + } + + return nil, nil +} + +func operationMilestoneActiveDirectoryUserMatches(user activeDirectoryUser, target string) bool { + for _, value := range []string{ + user.ID, + user.SID, + user.Username, + user.DisplayName, + user.Email, + user.DistinguishedName, + milestoneProtectedUserQualifiedName(user.Domain, user.Username), + } { + if normalizeOperationMilestoneAccountName(value) == target { + return true + } + } + + return false +} + +func operationMilestoneUserFromActiveDirectoryUser(user activeDirectoryUser) map[string]any { + userID := strings.TrimSpace(user.ID) + if userID == "" { + userID = strings.TrimSpace(user.SID) + } + if userID == "" { + userID = strings.TrimSpace(user.DistinguishedName) + } + if userID == "" { + userID = strings.TrimSpace(user.Username) + } + + displayName := strings.TrimSpace(user.DisplayName) + if displayName == "" { + displayName = strings.TrimSpace(user.Username) + } + if displayName == "" { + displayName = userID + } + + milestoneUser := map[string]any{ + "id": userID, + "name": displayName, + "displayName": displayName, + "userName": strings.TrimSpace(user.Username), + "username": strings.TrimSpace(user.Username), + "email": strings.TrimSpace(user.Email), + "relations": map[string]any{ + "self": map[string]any{ + "type": "users", + "id": userID, + }, + }, + } + if domain := strings.TrimSpace(user.Domain); domain != "" { + milestoneUser["domain"] = domain + } + if sid := strings.TrimSpace(user.SID); sid != "" { + milestoneUser["sid"] = sid + milestoneUser["objectSid"] = sid + } + if dn := strings.TrimSpace(user.DistinguishedName); dn != "" { + milestoneUser["distinguishedName"] = dn + } + + return milestoneUser +} + +func normalizeOperationMilestoneAccountName(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return "" + } + if _, account, ok := strings.Cut(value, "\\"); ok { + value = account + } + if account, _, ok := strings.Cut(value, "@"); ok { + value = account + } + + replacer := strings.NewReplacer(" ", "", "\t", "", "\n", "", "\r", "", "-", "", "_", "") + return replacer.Replace(value) +} + func (s *Server) assignOperationMilestoneCameraToRole( ctx context.Context, config milestoneRuntimeConfig, diff --git a/backend/routes.go b/backend/routes.go index ffd26df..3333935 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -36,6 +36,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /devices", s.requireAuth(s.handleListDevices)) mux.HandleFunc("POST /devices", s.requireAuth(s.handleCreateDevice)) mux.HandleFunc("PUT /devices/{id}", s.requireAuth(s.handleUpdateDevice)) + mux.HandleFunc("DELETE /devices/{id}", s.requireAuth(s.handleDeleteDevice)) mux.HandleFunc("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers)) mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory)) @@ -78,6 +79,10 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /device-lookups", s.handleDeviceLookups) mux.HandleFunc("POST /device-manufacturers", s.handleCreateDeviceManufacturer) + mux.HandleFunc( + "DELETE /device-manufacturers/{id}", + s.requireAuth(s.requireRight("devices:write", s.handleDeleteDeviceManufacturer)), + ) mux.HandleFunc("POST /device-locations", s.handleCreateDeviceLocation) mux.HandleFunc("GET /operations", s.requireAuth(s.requireRight("operations:read", s.handleListOperations))) @@ -131,7 +136,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /chat/link-preview", s.requireAuth(s.handleChatLinkPreview)) mux.HandleFunc("GET /chat/conversations/{id}/messages", s.requireAuth(s.handleListChatMessages)) mux.HandleFunc("POST /chat/conversations/{id}/messages", s.requireAuth(s.handleCreateChatMessage)) + mux.HandleFunc("POST /chat/messages/{id}/reactions", s.requireAuth(s.handleToggleChatMessageReaction)) mux.HandleFunc("POST /chat/conversations/{id}/read", s.requireAuth(s.handleMarkChatConversationRead)) + mux.HandleFunc("POST /chat/conversations/{id}/clear", s.requireAuth(s.handleClearChat)) mux.HandleFunc("PUT /chat/conversations/{id}/mute", s.requireAuth(s.handleUpdateConversationMute)) mux.HandleFunc("PUT /chat/conversations/{id}/disappearing", s.requireAuth(s.handleUpdateDisappearing)) mux.HandleFunc("POST /chat/groups", s.requireAuth(s.handleCreateGroup)) @@ -188,6 +195,9 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings))) mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken))) mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream))) + mux.HandleFunc("GET /admin/milestone/backup-logs", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneBackupLogs))) + mux.HandleFunc("GET /admin/milestone/backup-folders", s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneBackupFolders))) + mux.HandleFunc("POST /admin/milestone/backup-folders/browse", s.requireAuth(s.requireRight("administration:write", s.handleBrowseMilestoneBackupFolders))) mux.HandleFunc("GET /admin/milestone/basic-users", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneBasicUsers))) mux.HandleFunc("GET /admin/milestone/roles", s.requireAuth(s.requireRight("administration:read", s.handleAdminListMilestoneRoles))) mux.HandleFunc("POST /admin/milestone/roles", s.requireAuth(s.requireRight("administration:write", s.handleAdminCreateMilestoneRole))) diff --git a/backend/runtime_schema.go b/backend/runtime_schema.go index 63a0e01..16f4828 100644 --- a/backend/runtime_schema.go +++ b/backend/runtime_schema.go @@ -15,6 +15,38 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL`, `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS loaned_until DATE`, `ALTER TABLE IF EXISTS operations ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE IF EXISTS conversations ADD COLUMN IF NOT EXISTS disappearing_seconds INTEGER NOT NULL DEFAULT 0`, + `ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`, + ` + CREATE TABLE IF NOT EXISTS message_reactions ( + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (message_id, user_id) + ) + `, + ` + DELETE FROM message_reactions mr + USING ( + SELECT ctid + FROM ( + SELECT ctid, + ROW_NUMBER() OVER ( + PARTITION BY message_id, user_id + ORDER BY created_at DESC, emoji ASC + ) AS row_number + FROM message_reactions + ) ranked_reactions + WHERE row_number > 1 + ) duplicate_reactions + WHERE mr.ctid = duplicate_reactions.ctid + `, + `CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`, + `CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`, ` CREATE TABLE IF NOT EXISTS device_relations ( device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, @@ -47,6 +79,11 @@ func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error { `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_storage_root_path TEXT NOT NULL DEFAULT 'D:/Milestone-Speicher'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E'`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/'`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA`, + `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_base_dn TEXT NOT NULL DEFAULT ''`, `ALTER TABLE IF EXISTS milestone_settings ADD COLUMN IF NOT EXISTS active_directory_bind_username TEXT NOT NULL DEFAULT ''`, diff --git a/backend/search.go b/backend/search.go index 2328617..f85efec 100644 --- a/backend/search.go +++ b/backend/search.go @@ -66,7 +66,7 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) { return } - groups := make([]globalSearchGroup, 0, 5) + groups := make([]globalSearchGroup, 0, 6) if userCanSearch(user, "operations:read") { items, err := s.searchOperations(r.Context(), options) @@ -148,6 +148,20 @@ func (s *Server) handleGlobalSearch(w http.ResponseWriter, r *http.Request) { } } + items, err := s.searchPublicChatMessages(r.Context(), options) + if err != nil { + writeError(w, http.StatusInternalServerError, "Öffentliche Chats konnten nicht durchsucht werden") + return + } + + if len(items) > 0 { + groups = append(groups, globalSearchGroup{ + ID: "public-chat-messages", + Title: "Öffentliche Chats", + Items: items, + }) + } + writeJSON(w, http.StatusOK, globalSearchResponse{ Query: query, Groups: groups, @@ -677,6 +691,106 @@ func (s *Server) searchUsers(ctx context.Context, options globalSearchOptions) ( return items, rows.Err() } +func (s *Server) searchPublicChatMessages(ctx context.Context, options globalSearchOptions) ([]globalSearchItem, error) { + patterns := buildSearchPatterns(options) + + rows, err := s.db.Query( + ctx, + ` + SELECT + m.id::TEXT, + m.conversation_id::TEXT, + COALESCE( + NULLIF(c.name, ''), + ( + SELECT string_agg( + COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email), + ' & ' + ORDER BY lower(COALESCE(NULLIF(member.display_name, ''), NULLIF(member.username, ''), member.email)) + ) + FROM conversation_members cm + JOIN users member ON member.id = cm.user_id + WHERE cm.conversation_id = c.id + ), + 'Chat' + ), + COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), + m.body, + m.created_at + FROM messages m + JOIN conversations c ON c.id = m.conversation_id + LEFT JOIN users u ON u.id = m.sender_id + WHERE c.type <> 'channel' + AND c.is_public = true + AND m.deleted_at IS NULL + AND m.message_type = 'user' + AND (m.expires_at IS NULL OR m.expires_at > now()) + AND ( + ( + $3 = false + AND CONCAT_WS(' ', c.name, m.body) ILIKE ALL($1::TEXT[]) + ) + OR + ( + $3 = true + AND CONCAT_WS(' ', c.name, m.body) LIKE ALL($1::TEXT[]) + ) + ) + ORDER BY m.created_at DESC + LIMIT $2 + `, + patterns, + options.Limit, + options.CaseSensitive, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + items := []globalSearchItem{} + + for rows.Next() { + var messageID string + var conversationID string + var conversationName string + var senderDisplayName string + var body string + var createdAt time.Time + + if err := rows.Scan( + &messageID, + &conversationID, + &conversationName, + &senderDisplayName, + &body, + &createdAt, + ); err != nil { + return nil, err + } + + items = append(items, globalSearchItem{ + ID: "public-chat-message-" + messageID, + EntityID: conversationID, + EntityType: "publicChatMessage", + Title: conversationName, + Subtitle: joinSearchSubtitle( + formatSearchDate(createdAt), + senderDisplayName, + truncateSearchText(body, 140), + ), + Href: "/chat/" + conversationID, + Data: map[string]any{ + "messageId": messageID, + "conversationId": conversationID, + "createdAt": createdAt, + }, + }) + } + + return items, rows.Err() +} + func parseSearchLimit(value string, fallback int) int { limit, err := strconv.Atoi(value) if err != nil { diff --git a/backend/setup/main.go b/backend/setup/main.go index 789c88b..717997d 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -677,6 +677,11 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { server_folders_archive_root_label TEXT NOT NULL DEFAULT 'Archive E', server_folders_archive_root_path TEXT NOT NULL DEFAULT 'E:/', server_folders_agent_token_encrypted BYTEA, + backup_nas_host TEXT NOT NULL DEFAULT '', + backup_nas_share TEXT NOT NULL DEFAULT '', + backup_nas_username TEXT NOT NULL DEFAULT '', + backup_nas_password_encrypted BYTEA, + backup_log_root_path TEXT NOT NULL DEFAULT '', active_directory_server_url TEXT NOT NULL DEFAULT '', active_directory_base_dn TEXT NOT NULL DEFAULT '', active_directory_bind_username TEXT NOT NULL DEFAULT '', @@ -724,6 +729,31 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA `, + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS backup_nas_host TEXT NOT NULL DEFAULT '' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS backup_nas_share TEXT NOT NULL DEFAULT '' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS backup_nas_username TEXT NOT NULL DEFAULT '' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS backup_nas_password_encrypted BYTEA + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS backup_log_root_path TEXT NOT NULL DEFAULT '' + `, + ` ALTER TABLE milestone_settings ADD COLUMN IF NOT EXISTS active_directory_server_url TEXT NOT NULL DEFAULT '' @@ -929,6 +959,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, last_read_at TIMESTAMPTZ, + last_cleared_at TIMESTAMPTZ, muted BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), @@ -938,6 +969,7 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `, `ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS muted BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE IF EXISTS conversation_members ADD COLUMN IF NOT EXISTS last_cleared_at TIMESTAMPTZ`, ` CREATE TABLE IF NOT EXISTS messages ( @@ -987,6 +1019,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS message_reactions ( + message_id UUID NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + + PRIMARY KEY (message_id, user_id) + ) + `, + // Link-Vorschau (OpenGraph-Metadaten), serverseitig asynchron geladen. // Eine Vorschau pro Nachricht (erste erkannte URL). ` @@ -1151,6 +1194,25 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_messages_expires_at ON messages(expires_at) WHERE expires_at IS NOT NULL`, `CREATE INDEX IF NOT EXISTS idx_message_attachments_message_id ON message_attachments(message_id)`, `CREATE INDEX IF NOT EXISTS idx_message_attachments_uploader_id ON message_attachments(uploader_id)`, + ` + DELETE FROM message_reactions mr + USING ( + SELECT ctid + FROM ( + SELECT ctid, + ROW_NUMBER() OVER ( + PARTITION BY message_id, user_id + ORDER BY created_at DESC, emoji ASC + ) AS row_number + FROM message_reactions + ) ranked_reactions + WHERE row_number > 1 + ) duplicate_reactions + WHERE mr.ctid = duplicate_reactions.ctid + `, + `CREATE INDEX IF NOT EXISTS idx_message_reactions_message_id ON message_reactions(message_id)`, + `CREATE INDEX IF NOT EXISTS idx_message_reactions_user_id ON message_reactions(user_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_message_reactions_message_user_unique ON message_reactions(message_id, user_id)`, } for _, query := range queries { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9f15346..884a1d6 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -7,6 +7,7 @@ import LoginPage from './pages/LoginPage' import AppLayout from './components/AppLayout' import DashboardPage from './pages/DashboardPage' import DevicesPage from './pages/devices/DevicesPage' +import DeviceCategoryPage from './pages/devices/DeviceCategoryPage' import OperationsPage from './pages/operations/OperationsPage' import CalendarPage from './pages/calendar/CalendarPage' import DocumentsPage from './pages/DocumentsPage' @@ -22,6 +23,7 @@ import MilestonePage from './pages/milestone/MilestonePage' import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage' import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage' import MilestoneRolesPage from './pages/milestone/rollen/MilestoneRolesPage' +import MilestoneBackupPage from './pages/milestone/backup/MilestoneBackupPage' import { hasRight } from './components/permissions' import ChatPage from './pages/chat/ChatPage' import { ChatSocketProvider } from './components/ChatSocketProvider' @@ -655,6 +657,75 @@ function App() { } /> + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + + + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + } + /> + + + + + } + /> + } @@ -719,7 +824,7 @@ function App() { } + element={} /> + + diff --git a/frontend/src/components/Feedback.tsx b/frontend/src/components/Feedback.tsx index 4b8420f..28572d2 100644 --- a/frontend/src/components/Feedback.tsx +++ b/frontend/src/components/Feedback.tsx @@ -3,9 +3,11 @@ import { useMemo, useState, type FormEvent } from 'react' import { ChatBubbleBottomCenterTextIcon, + ChevronDownIcon, CheckCircleIcon, CursorArrowRaysIcon, ExclamationTriangleIcon, + PaperAirplaneIcon, ShieldCheckIcon, } from '@heroicons/react/24/outline' import Button from './Button' @@ -52,6 +54,19 @@ export default function Feedback() { [logSnapshot], ) const actionCount = logSnapshot.length - errorCount + const sortedLogSnapshot = useMemo( + () => + [...logSnapshot].sort((left, right) => { + const leftTime = Date.parse(left.timestamp) + const rightTime = Date.parse(right.timestamp) + + return ( + (Number.isNaN(rightTime) ? 0 : rightTime) - + (Number.isNaN(leftTime) ? 0 : leftTime) + ) + }), + [logSnapshot], + ) async function handleSubmit(event: FormEvent) { event.preventDefault() @@ -74,7 +89,7 @@ export default function Feedback() { credentials: 'include', body: JSON.stringify({ message: trimmedMessage, - clientLog: logSnapshot, + clientLog: sortedLogSnapshot, client, }), }) @@ -181,21 +196,24 @@ export default function Feedback() { type="submit" isLoading={isSubmitting} disabled={!message.trim() || isSubmitting} + leadingIcon={ +