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={
+
+ }
>
Feedback senden
-
-
-
+
+
+
Angehängtes Protokoll
- Deine letzten Aktionen und Fehler in dieser Sitzung – hilft
- uns, Probleme nachzuvollziehen.
+ Deine letzten Schaltflächen-Klicks, Seitenaufrufe und
+ API-Fehler in dieser Sitzung.
Es werden keine Passwörter oder Eingabeinhalte erfasst – nur,
- welche Elemente du benutzt hast, und technische Fehlermeldungen.
+ welche Schaltflächen du benutzt hast, Seitenaufrufe und
+ API-Fehler.
-
+
>
)}
diff --git a/frontend/src/components/Journal.tsx b/frontend/src/components/Journal.tsx
index 4520b8b..5d0cc62 100644
--- a/frontend/src/components/Journal.tsx
+++ b/frontend/src/components/Journal.tsx
@@ -404,7 +404,7 @@ export default function Journal({
{changes.length}{' '}
diff --git a/frontend/src/components/NotificationCenter.tsx b/frontend/src/components/NotificationCenter.tsx
index 208df7a..7da4033 100644
--- a/frontend/src/components/NotificationCenter.tsx
+++ b/frontend/src/components/NotificationCenter.tsx
@@ -2,10 +2,11 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
- BellIcon,
EnvelopeOpenIcon,
FaceSmileIcon,
} from '@heroicons/react/20/solid'
+import { BellIcon as BellOutlineIcon } from '@heroicons/react/24/outline'
+import { BellIcon as BellSolidIcon } from '@heroicons/react/24/solid'
import Button from './Button'
import Tabs, { type TabItem } from './Tabs'
import type { Notification } from './types'
@@ -115,6 +116,8 @@ export default function NotificationCenter({
setOpen(false)
}
+ const BellIcon = open ? BellSolidIcon : BellOutlineIcon
+
return (