diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e2e620a..0221e9d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -5,7 +5,9 @@ "PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 40; \"TSC EXIT: $LASTEXITCODE\")", "PowerShell(Set-Location \"c:\\\\Users\\\\Rother\\\\fork\\\\teg\\\\frontend\"; npx tsc --noEmit 2>&1 | Select-Object -First 30; \"TSC EXIT: $LASTEXITCODE\")", "Bash(npx tsc *)", - "Bash(echo \"EXIT:$?\")" + "Bash(echo \"EXIT:$?\")", + "Bash(go build *)", + "Bash(xargs grep -l \"upgrade\")" ] } } diff --git a/backend/.env b/backend/.env index 7fd8c8e..1c8a4f2 100644 --- a/backend/.env +++ b/backend/.env @@ -1,7 +1,7 @@ PORT=8080 DATABASE_URL=postgres://postgres:Timmy0104199%3F@localhost:5432/teg?sslmode=disable JWT_SECRET=tegvideo7010! -FRONTEND_ORIGIN=https://localhost:5173 +FRONTEND_ORIGIN=https://10.0.1.25:5173 ADDRESS_SEARCH_PROVIDER=photon ADDRESS_SEARCH_URL=https://photon.komoot.io/api ADDRESS_SEARCH_LANGUAGE=de diff --git a/backend/admin.go b/backend/admin.go index 246b82c..6fe83f8 100644 --- a/backend/admin.go +++ b/backend/admin.go @@ -1,4 +1,4 @@ -// backend/admin.go +// backend\admin.go package main @@ -83,15 +83,13 @@ func ensureAdminUserLookupValues(ctx context.Context, tx pgx.Tx, unit string) er func normalizeAdminTeamInput(input *AdminTeamRequest) { input.Name = strings.TrimSpace(input.Name) input.Initial = strings.TrimSpace(input.Initial) - input.Href = strings.TrimSpace(input.Href) if input.Initial == "" && input.Name != "" { input.Initial = strings.ToUpper(string([]rune(input.Name)[0])) } - if input.Href == "" { - input.Href = "#" - } + // Der Link entspricht immer dem Teamnamen; ein separates Feld gibt es nicht mehr. + input.Href = input.Name } func cleanStringList(values []string) []string { diff --git a/backend/admin_group_chats.go b/backend/admin_group_chats.go index 3a55b3b..683d291 100644 --- a/backend/admin_group_chats.go +++ b/backend/admin_group_chats.go @@ -15,6 +15,7 @@ type AdminGroupChat struct { Name string `json:"name"` Image string `json:"image"` OwnerID string `json:"ownerId"` + TeamID string `json:"teamId"` UserIDs []string `json:"userIds"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` @@ -35,6 +36,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques c.name, c.channel_image, COALESCE(c.created_by::TEXT, ''), + COALESCE(c.team_id::TEXT, ''), COALESCE( array_agg(cm.user_id::TEXT ORDER BY cm.created_at) FILTER (WHERE cm.user_id IS NOT NULL), @@ -45,9 +47,8 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques FROM conversations c LEFT JOIN conversation_members cm ON cm.conversation_id = c.id WHERE c.type = 'group' - AND c.team_id IS NULL GROUP BY c.id - ORDER BY lower(c.name), c.created_at + ORDER BY (c.team_id IS NOT NULL), lower(c.name), c.created_at `, ) if err != nil { @@ -64,6 +65,7 @@ func (s *Server) handleAdminListGroupChats(w http.ResponseWriter, r *http.Reques &group.Name, &group.Image, &group.OwnerID, + &group.TeamID, &group.UserIDs, &group.CreatedAt, &group.UpdatedAt, @@ -315,6 +317,75 @@ func (s *Server) handleAdminDeleteGroupChat(w http.ResponseWriter, r *http.Reque writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } +// handleAdminEmptyGroupChat löscht den gesamten Nachrichtenverlauf eines +// Gruppenchats (inkl. Anhänge über ON DELETE CASCADE), behält den Chat aber. +// Für Team-Gruppenchats ist dies die einzige zulässige Admin-Aktion. +func (s *Server) handleAdminEmptyGroupChat(w http.ResponseWriter, r *http.Request) { + conversationID := strings.TrimSpace(r.PathValue("id")) + if conversationID == "" { + writeError(w, http.StatusBadRequest, "Gruppenchat-ID fehlt") + return + } + + tx, err := s.db.Begin(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geleert werden") + return + } + defer tx.Rollback(r.Context()) + + var exists bool + if err := tx.QueryRow( + r.Context(), + `SELECT true FROM conversations WHERE id = $1 AND type = 'group'`, + conversationID, + ).Scan(&exists); err != nil { + if err == pgx.ErrNoRows { + writeError(w, http.StatusNotFound, "Gruppenchat wurde nicht gefunden") + return + } + writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geladen werden") + return + } + + if _, err := tx.Exec( + r.Context(), + `DELETE FROM notifications WHERE entity_type = 'chat' AND entity_id = $1`, + conversationID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Chat-Benachrichtigungen konnten nicht gelöscht werden") + return + } + + if _, err := tx.Exec( + r.Context(), + `DELETE FROM messages WHERE conversation_id = $1`, + conversationID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Nachrichten konnten nicht gelöscht werden") + return + } + + if _, err := tx.Exec( + r.Context(), + `UPDATE conversations SET last_message_at = NULL, updated_at = now() WHERE id = $1`, + conversationID, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht aktualisiert werden") + return + } + + if err := tx.Commit(r.Context()); err != nil { + writeError(w, http.StatusInternalServerError, "Gruppenchat konnte nicht geleert werden") + return + } + + s.publishChatMessagesReload(r.Context(), conversationID) + s.publishConversationUpdate(r.Context(), conversationID) + + writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) +} + func adminGroupMemberIDs(ctx context.Context, tx pgx.Tx, conversationID string) ([]string, error) { rows, err := tx.Query( ctx, diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index dd30047..1f1de27 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -291,13 +291,6 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re // der Schritte fehl, bleibt der bisherige Token unverändert und es wird eine // Warnung zurückgegeben (das Speichern der übrigen Einstellungen bleibt gültig). func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) string { - newToken, err := generateServerFoldersAgentToken() - if err != nil { - log.Printf("server-folders agent token generation failed: %v", err) - - return "Der Server-Folders-Agent-Token konnte nicht erzeugt werden." - } - config, err := s.getServerFoldersAgentConfig(ctx) if err != nil { log.Printf("server-folders agent config load failed: %v", err) @@ -305,29 +298,12 @@ func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) str return "Der Server-Folders-Agent-Token konnte nicht ausgetauscht werden." } - if err := s.pushServerFoldersAgentToken(ctx, config, newToken); err != nil { + if _, err := s.rotateServerFoldersAgentToken(ctx, config); err != nil { log.Printf("server-folders agent token rotation failed: %v", err) return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht." } - if _, err := s.db.Exec( - ctx, - ` - UPDATE milestone_settings - SET - server_folders_agent_token_encrypted = pgp_sym_encrypt($1, $2), - updated_at = now() - WHERE id = 1 - `, - newToken, - s.milestoneEncryptionKey(), - ); err != nil { - log.Printf("server-folders agent token persist failed: %v", err) - - return "Der neue Server-Folders-Agent-Token konnte nicht gespeichert werden." - } - return "" } diff --git a/backend/channels.go b/backend/channels.go index 553a2c8..3c57556 100644 --- a/backend/channels.go +++ b/backend/channels.go @@ -62,6 +62,7 @@ type AdminChannelUser struct { DisplayName string `json:"displayName"` Email string `json:"email"` Unit string `json:"unit"` + Avatar string `json:"avatar"` } type saveAdminChannelRequest struct { @@ -218,7 +219,8 @@ func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser, COALESCE(username, ''), COALESCE(display_name, ''), email, - COALESCE(unit, '') + COALESCE(unit, ''), + COALESCE(avatar, '') FROM users ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)) `, @@ -237,6 +239,7 @@ func (s *Server) listAdminChannelUsers(ctx context.Context) ([]AdminChannelUser, &user.DisplayName, &user.Email, &user.Unit, + &user.Avatar, ); err != nil { return nil, err } diff --git a/backend/chat.go b/backend/chat.go index 3f6ab6b..4fd1bc4 100644 --- a/backend/chat.go +++ b/backend/chat.go @@ -284,6 +284,7 @@ func (s *Server) handleListChatConversations(w http.ResponseWriter, r *http.Requ AND current_membership.user_id = $1 WHERE unread_message.conversation_id = c.id AND unread_message.deleted_at IS NULL + 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 unread_message.created_at > COALESCE( @@ -1002,6 +1003,7 @@ func (s *Server) getChatConversation(ctx context.Context, conversationID string, AND current_membership.user_id = $2 WHERE unread_message.conversation_id = c.id AND unread_message.deleted_at IS NULL + 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 unread_message.created_at > COALESCE( diff --git a/backend/chat_unread.go b/backend/chat_unread.go index 00c50a6..51496d5 100644 --- a/backend/chat_unread.go +++ b/backend/chat_unread.go @@ -41,6 +41,7 @@ func (s *Server) getChatUnreadCount(ctx context.Context, userID string) (int, er ON cm.conversation_id = c.id AND cm.user_id = $1 WHERE m.deleted_at IS NULL + AND m.message_type <> 'system' AND (m.expires_at IS NULL OR m.expires_at > now()) AND m.sender_id IS DISTINCT FROM $1::UUID AND m.created_at > COALESCE(cm.last_read_at, '-infinity'::TIMESTAMPTZ) diff --git a/backend/chat_websocket.go b/backend/chat_websocket.go index 9883860..5d9e9ed 100644 --- a/backend/chat_websocket.go +++ b/backend/chat_websocket.go @@ -1,3 +1,5 @@ +// backend\chat_websocket.go + package main import ( @@ -5,6 +7,7 @@ import ( "encoding/json" "errors" "net/http" + "net/url" "strings" "sync" "time" @@ -99,9 +102,17 @@ func (s *Server) handleChatWebSocket(w http.ResponseWriter, r *http.Request) { return } + // coder/websocket matcht OriginPatterns gegen den Host der Origin (z. B. + // "localhost:5173"). Daher nur den Host übergeben – schema-unabhängig, damit + // http/https sowie ein Dev-Proxy mit geändertem Host (changeOrigin) den + // Upgrade nicht blockieren. originPatterns := []string{} if frontendOrigin := strings.TrimRight(s.frontendOrigin, "/"); frontendOrigin != "" { - originPatterns = append(originPatterns, frontendOrigin) + host := frontendOrigin + if parsed, err := url.Parse(frontendOrigin); err == nil && parsed.Host != "" { + host = parsed.Host + } + originPatterns = append(originPatterns, host) } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ diff --git a/backend/devices.go b/backend/devices.go index 21fd470..fd81cc7 100644 --- a/backend/devices.go +++ b/backend/devices.go @@ -21,9 +21,15 @@ type Device struct { SerialNumber string `json:"serialNumber"` MacAddress string `json:"macAddress"` IPAddress string `json:"ipAddress"` + PhoneNumber string `json:"phoneNumber"` + ComputerName string `json:"computerName"` + DeviceCategory string `json:"deviceCategory"` MilestonePort string `json:"milestonePort"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` + LoanedToUserID string `json:"loanedToUserId"` + LoanedToDisplayName string `json:"loanedToDisplayName"` + LoanedUntil string `json:"loanedUntil"` IsBaoDevice bool `json:"isBaoDevice"` Comment string `json:"comment"` FirmwareVersion string `json:"firmwareVersion"` @@ -64,6 +70,15 @@ type DeviceShort struct { Model string `json:"model"` } +type DeviceLoanUser struct { + ID string `json:"id"` + Username string `json:"username"` + DisplayName string `json:"displayName"` + Email string `json:"email"` + Avatar string `json:"avatar"` + Unit string `json:"unit"` +} + type MilestoneHardwareChildDevice struct { ID string `json:"id"` MilestoneHardwareID string `json:"milestoneHardwareId"` @@ -85,12 +100,17 @@ type CreateDeviceRequest struct { SerialNumber string `json:"serialNumber"` MacAddress string `json:"macAddress"` IPAddress string `json:"ipAddress"` + PhoneNumber string `json:"phoneNumber"` + ComputerName string `json:"computerName"` + DeviceCategory string `json:"deviceCategory"` MilestonePort string `json:"milestonePort"` FirmwareVersion string `json:"firmwareVersion"` MilestoneDisplayName string `json:"milestoneDisplayName"` MilestoneEnabled *bool `json:"milestoneEnabled,omitempty"` Location string `json:"location"` LoanStatus string `json:"loanStatus"` + LoanedToUserID string `json:"loanedToUserId"` + LoanedUntil string `json:"loanedUntil"` IsBaoDevice bool `json:"isBaoDevice"` Comment string `json:"comment"` RelatedDeviceIDs []string `json:"relatedDeviceIds"` @@ -115,37 +135,94 @@ func (s *Server) handleDevices(w http.ResponseWriter, r *http.Request) { } } +func (s *Server) handleListDeviceLoanUsers(w http.ResponseWriter, r *http.Request) { + rows, err := s.db.Query( + r.Context(), + ` + SELECT + id::TEXT, + COALESCE(username, ''), + COALESCE(display_name, ''), + email, + COALESCE(avatar, ''), + COALESCE(unit, '') + FROM users + ORDER BY lower(COALESCE(NULLIF(display_name, ''), username, email)), lower(email) + `, + ) + if err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht geladen werden") + return + } + defer rows.Close() + + users := []DeviceLoanUser{} + + for rows.Next() { + var user DeviceLoanUser + if err := rows.Scan( + &user.ID, + &user.Username, + &user.DisplayName, + &user.Email, + &user.Avatar, + &user.Unit, + ); err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht gelesen werden") + return + } + + users = append(users, user) + } + + if err := rows.Err(); err != nil { + writeError(w, http.StatusInternalServerError, "Benutzer konnten nicht vollständig geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{"users": users}) +} + func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { rows, err := s.db.Query( r.Context(), ` SELECT - id, - inventory_number, - manufacturer, - model, - serial_number, - mac_address, - COALESCE(ip_address, ''), - COALESCE(milestone_port, ''), - location, - loan_status, - is_bao_device, - comment, - COALESCE(firmware_version, ''), - COALESCE(milestone_recording_server_id, ''), - COALESCE(milestone_hardware_id, ''), - COALESCE(milestone_display_name, ''), - milestone_enabled, - milestone_synced_at, - created_at, - updated_at + devices.id, + COALESCE(devices.inventory_number, ''), + COALESCE(devices.manufacturer, ''), + COALESCE(devices.model, ''), + COALESCE(devices.serial_number, ''), + COALESCE(devices.mac_address, ''), + COALESCE(devices.ip_address, ''), + COALESCE(devices.phone_number, ''), + COALESCE(devices.computer_name, ''), + COALESCE(devices.device_category, ''), + COALESCE(devices.milestone_port, ''), + COALESCE(devices.location, ''), + COALESCE(devices.loan_status, 'Verfügbar'), + COALESCE(devices.loaned_to_user_id::TEXT, ''), + COALESCE(NULLIF(loaned_user.display_name, ''), NULLIF(loaned_user.username, ''), loaned_user.email, ''), + COALESCE(devices.loaned_until::TEXT, ''), + COALESCE(devices.is_bao_device, false), + COALESCE(devices.comment, ''), + COALESCE(devices.firmware_version, ''), + COALESCE(devices.milestone_recording_server_id, ''), + COALESCE(devices.milestone_hardware_id, ''), + COALESCE(devices.milestone_display_name, ''), + COALESCE(devices.milestone_enabled, true), + devices.milestone_synced_at, + devices.created_at, + devices.updated_at FROM devices - ORDER BY inventory_number ASC + LEFT JOIN users AS loaned_user + ON loaned_user.id = devices.loaned_to_user_id + ORDER BY devices.inventory_number ASC `, ) if err != nil { + logError("Geräte konnten nicht geladen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not load devices") return } @@ -165,9 +242,15 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.PhoneNumber, + &device.ComputerName, + &device.DeviceCategory, &device.MilestonePort, &device.Location, &device.LoanStatus, + &device.LoanedToUserID, + &device.LoanedToDisplayName, + &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.FirmwareVersion, @@ -179,6 +262,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &device.CreatedAt, &device.UpdatedAt, ); err != nil { + logError("Geräte konnten nicht gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read devices") return } @@ -191,6 +275,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { } if err := rows.Err(); err != nil { + logError("Geräte konnten nicht vollständig gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read devices") return } @@ -201,9 +286,9 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { SELECT device_relations.device_id, related_devices.id, - related_devices.inventory_number, - related_devices.manufacturer, - related_devices.model + COALESCE(related_devices.inventory_number, ''), + COALESCE(related_devices.manufacturer, ''), + COALESCE(related_devices.model, '') FROM device_relations INNER JOIN devices AS related_devices ON related_devices.id = device_relations.related_device_id @@ -212,6 +297,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { ) if err != nil { + logError("Geräte-Zuordnungen konnten nicht geladen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not load device relations") return } @@ -228,6 +314,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &relatedDevice.Manufacturer, &relatedDevice.Model, ); err != nil { + logError("Geräte-Zuordnungen konnten nicht gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read device relations") return } @@ -242,6 +329,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { } if err := relationRows.Err(); err != nil { + logError("Geräte-Zuordnungen konnten nicht vollständig gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read device relations") return } @@ -252,14 +340,14 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { SELECT devices.id::TEXT, milestone_hardware_child_devices.id::TEXT, - milestone_hardware_child_devices.milestone_hardware_id, - milestone_hardware_child_devices.milestone_device_id, - milestone_hardware_child_devices.device_type, - milestone_hardware_child_devices.name, - milestone_hardware_child_devices.display_name, - milestone_hardware_child_devices.short_name, - milestone_hardware_child_devices.enabled, - COALESCE(milestone_hardware_child_devices.recording_enabled, false), + COALESCE(milestone_hardware_child_devices.milestone_hardware_id, ''), + COALESCE(milestone_hardware_child_devices.milestone_device_id, ''), + COALESCE(milestone_hardware_child_devices.device_type, ''), + COALESCE(milestone_hardware_child_devices.name, ''), + COALESCE(milestone_hardware_child_devices.display_name, ''), + COALESCE(milestone_hardware_child_devices.short_name, ''), + COALESCE(milestone_hardware_child_devices.enabled, true), + COALESCE(milestone_hardware_child_devices.recording_enabled, false), milestone_hardware_child_devices.created_at, milestone_hardware_child_devices.updated_at FROM milestone_hardware_child_devices @@ -273,6 +361,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { `, ) if err != nil { + logError("Milestone-Child-Devices konnten nicht geladen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not load Milestone child devices") return } @@ -296,6 +385,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &childDevice.CreatedAt, &childDevice.UpdatedAt, ); err != nil { + logError("Milestone-Child-Devices konnten nicht gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices") return } @@ -310,6 +400,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { } if err := childRows.Err(); err != nil { + logError("Milestone-Child-Devices konnten nicht vollständig gelesen werden", err, nil) writeError(w, http.StatusInternalServerError, "Could not read Milestone child devices") return } @@ -366,14 +457,19 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { serial_number, mac_address, ip_address, + phone_number, + computer_name, + device_category, milestone_port, location, loan_status, + loaned_to_user_id, + loaned_until, is_bao_device, comment, firmware_version ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, NULLIF($13, '')::UUID, NULLIF($14, '')::DATE, $15, $16, $17) RETURNING id, inventory_number, @@ -382,9 +478,15 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { serial_number, mac_address, COALESCE(ip_address, ''), + COALESCE(phone_number, ''), + COALESCE(computer_name, ''), + COALESCE(device_category, ''), COALESCE(milestone_port, ''), location, loan_status, + COALESCE(loaned_to_user_id::TEXT, ''), + '', + COALESCE(loaned_until::TEXT, ''), is_bao_device, comment, COALESCE(firmware_version, ''), @@ -402,9 +504,14 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { input.SerialNumber, input.MacAddress, input.IPAddress, + input.PhoneNumber, + input.ComputerName, + input.DeviceCategory, input.MilestonePort, input.Location, input.LoanStatus, + input.LoanedToUserID, + input.LoanedUntil, input.IsBaoDevice, input.Comment, input.FirmwareVersion, @@ -416,9 +523,15 @@ func (s *Server) handleCreateDevice(w http.ResponseWriter, r *http.Request) { &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.PhoneNumber, + &device.ComputerName, + &device.DeviceCategory, &device.MilestonePort, &device.Location, &device.LoanStatus, + &device.LoanedToUserID, + &device.LoanedToDisplayName, + &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.FirmwareVersion, @@ -545,6 +658,9 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { } if err != nil { + logError("Device history state konnte nicht geladen werden", err, LogFields{ + "deviceId": deviceID, + }) writeError(w, http.StatusInternalServerError, "Could not load device history state") return } @@ -567,21 +683,26 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { serial_number = $5, mac_address = $6, ip_address = $7, - milestone_port = $8, - location = $9, - loan_status = $10, - is_bao_device = $11, - comment = $12, - firmware_version = $13, - milestone_display_name = $14, - milestone_enabled = $15, + phone_number = $8, + computer_name = $9, + device_category = $10, + milestone_port = $11, + location = $12, + loan_status = $13, + loaned_to_user_id = NULLIF($14, '')::UUID, + loaned_until = NULLIF($15, '')::DATE, + is_bao_device = $16, + comment = $17, + firmware_version = $18, + milestone_display_name = $19, + milestone_enabled = $20, milestone_synced_at = CASE WHEN COALESCE(milestone_hardware_id, '') <> '' AND ( - COALESCE(milestone_display_name, '') <> $14 + COALESCE(milestone_display_name, '') <> $19 OR COALESCE(ip_address, '') <> $7 - OR COALESCE(milestone_port, '') <> $8 - OR milestone_enabled <> $15 + OR COALESCE(milestone_port, '') <> $11 + OR milestone_enabled <> $20 ) THEN now() ELSE milestone_synced_at @@ -596,9 +717,15 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { serial_number, mac_address, COALESCE(ip_address, ''), + COALESCE(phone_number, ''), + COALESCE(computer_name, ''), + COALESCE(device_category, ''), COALESCE(milestone_port, ''), location, loan_status, + COALESCE(loaned_to_user_id::TEXT, ''), + '', + COALESCE(loaned_until::TEXT, ''), is_bao_device, comment, COALESCE(firmware_version, ''), @@ -617,9 +744,14 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { input.SerialNumber, input.MacAddress, input.IPAddress, + input.PhoneNumber, + input.ComputerName, + input.DeviceCategory, input.MilestonePort, input.Location, input.LoanStatus, + input.LoanedToUserID, + input.LoanedUntil, input.IsBaoDevice, input.Comment, input.FirmwareVersion, @@ -633,9 +765,15 @@ func (s *Server) handleUpdateDevice(w http.ResponseWriter, r *http.Request) { &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.PhoneNumber, + &device.ComputerName, + &device.DeviceCategory, &device.MilestonePort, &device.Location, &device.LoanStatus, + &device.LoanedToUserID, + &device.LoanedToDisplayName, + &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.FirmwareVersion, @@ -756,16 +894,26 @@ func normalizeDeviceInput(input *CreateDeviceRequest) { input.SerialNumber = strings.TrimSpace(input.SerialNumber) input.MacAddress = strings.TrimSpace(input.MacAddress) input.IPAddress = strings.TrimSpace(input.IPAddress) + input.PhoneNumber = strings.TrimSpace(input.PhoneNumber) + input.ComputerName = strings.TrimSpace(input.ComputerName) + input.DeviceCategory = strings.TrimSpace(input.DeviceCategory) input.MilestonePort = strings.TrimSpace(input.MilestonePort) input.FirmwareVersion = strings.TrimSpace(input.FirmwareVersion) input.MilestoneDisplayName = strings.TrimSpace(input.MilestoneDisplayName) input.Location = strings.TrimSpace(input.Location) input.LoanStatus = strings.TrimSpace(input.LoanStatus) + input.LoanedToUserID = strings.TrimSpace(input.LoanedToUserID) + input.LoanedUntil = strings.TrimSpace(input.LoanedUntil) input.Comment = strings.TrimSpace(input.Comment) if input.LoanStatus == "" { input.LoanStatus = "Verfügbar" } + + if !strings.EqualFold(input.LoanStatus, "Ausgeliehen") { + input.LoanedToUserID = "" + input.LoanedUntil = "" + } } func syncDeviceRelations(ctx context.Context, tx pgx.Tx, deviceID string, relatedDeviceIDs []string) error { diff --git a/backend/external/README.md b/backend/external/README.md index 490c90c..051ff06 100644 --- a/backend/external/README.md +++ b/backend/external/README.md @@ -1,4 +1,96 @@ -# Server Folders mit Parametern +# Server Folders Agent per JSON + +## Agent starten + +Lege `server-folders-agent.json` neben die `server-folders-agent.exe` und starte den Agent ohne weitere Parameter: + +```powershell +.\server-folders-agent.exe +``` + +Beispiel: + +```json +{ + "listenAddr": ":8099", + "storageRoots": [ + { + "label": "Storage D", + "path": "D:\\Milestone\\Storage" + } + ], + "archiveRoots": [ + { + "label": "Archive E", + "path": "E:\\Milestone\\Archive" + } + ], + "token": "" +} +``` + +Alternativ kann die Config explizit angegeben werden: + +```powershell +.\server-folders-agent.exe -config "C:\Milestone\server-folders-agent.json" +``` + +Oder per ENV: + +```powershell +$env:SERVER_FOLDER_AGENT_CONFIG="C:\Milestone\server-folders-agent.json" +.\server-folders-agent.exe +``` + +## Token-Handling + +Der Agent kann ohne initialen Token gestartet werden. Das Backend erzeugt beim Speichern der Milestone-Einstellungen automatisch einen zufälligen Token und überträgt ihn per `PUT /agent-token` an den Agent. + +Wenn keine `tokenFile` in der JSON gesetzt ist, schreibt der Agent den aktuellen Token direkt ins Feld `token` der JSON-Datei. Damit überlebt der Token einen Neustart und alle Einstellungen liegen in einer Datei. + +```http +PUT /agent-token +X-Server-Folders-Token: # nur nötig, wenn bereits ein Token gesetzt ist +Content-Type: application/json + +{ "token": "" } +``` + +Solange noch kein Token gesetzt ist, akzeptiert der Agent `PUT /agent-token` auch ohne Header. Betreibe den Agent daher in einem vertrauenswürdigen Netzwerk und speichere die Einstellungen nach dem Start zeitnah. + +Falls du den Token weiterhin getrennt speichern möchtest, kannst du in der JSON `tokenFile` setzen: + +```json +{ + "listenAddr": ":8099", + "storageRoots": [ + { + "label": "Storage D", + "path": "D:\\Milestone\\Storage" + } + ], + "tokenFile": "C:\\Milestone\\server-folders-agent.token" +} +``` + +## Kompatibilität + +Die bisherigen Parameter und ENV-Variablen funktionieren weiter und überschreiben Werte aus der JSON: + +```powershell +.\server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" +``` + +Unterstützte ENV-Variablen: + +- `SERVER_FOLDER_AGENT_CONFIG` +- `LISTEN_ADDR` +- `SERVER_FOLDER_AGENT_PORT` +- `SERVER_STORAGE_ROOTS` +- `SERVER_ARCHIVE_ROOTS` +- `SERVER_FOLDER_ROOTS` +- `SERVER_FOLDER_AGENT_TOKEN` +- `SERVER_FOLDER_AGENT_TOKEN_FILE` ## Backend-Proxy @@ -9,63 +101,8 @@ GET /admin/server-folders?path=D:\Milestone POST /admin/server-folders ``` -Zusätzlich kann der Proxy diese Parameter auswerten: - -```http -GET /admin/server-folders?path=D:\Milestone&schema=http&port=8099&token=CHANGE-ME -``` - -Aliasnamen: - -- `schema`, `scheme`, `agentSchema`, `agentScheme` -- `port`, `agentPort` -- `token`, `agentToken` - -Der Proxy entfernt diese Parameter, bevor er an den Agent weiterleitet. -Der Token wird als Header weitergegeben: +Der Proxy gibt den Token als Header an den Agent weiter: ```http X-Server-Folders-Token: ``` - -## Token-Handling (automatisch) - -Der Agent wird **ohne** Token gestartet. Das Backend erzeugt beim Speichern der -Milestone-Einstellungen automatisch einen zufälligen Token und überträgt ihn per -`PUT /agent-token` an den Agent. Der Agent persistiert den Token in einer Datei -(Standard: neben der `.exe`) und nutzt ihn danach für alle weiteren Anfragen – -auch nach einem Neustart. - -```http -PUT /agent-token -X-Server-Folders-Token: # nur nötig, wenn bereits ein Token gesetzt ist -Content-Type: application/json - -{ "token": "" } -``` - -Solange noch kein Token gesetzt ist, akzeptiert der Agent `PUT /agent-token` auch -ohne Header (Bootstrap). Betreibe den Agent daher in einem vertrauenswürdigen -Netzwerk und speichere die Einstellungen nach dem Start zeitnah. - -## Agent starten - -```powershell -.\server-folders-agent.exe -port 8099 -roots "Milestone D=D:\Milestone;Milestone E=E:\Milestone" -``` - -Alternativ weiter per ENV: - -```powershell -$env:LISTEN_ADDR=":8099" -$env:SERVER_FOLDER_ROOTS="Milestone D=D:\Milestone;Milestone E=E:\Milestone" - -.\server-folders-agent.exe -``` - -Optionale Parameter: - -- `-token` / `SERVER_FOLDER_AGENT_TOKEN`: nur als Start-Seed, falls noch keine - Token-Datei existiert (in der Regel nicht nötig). -- `-token-file` / `SERVER_FOLDER_AGENT_TOKEN_FILE`: Pfad der Token-Datei - (Standard: `server-folders-agent.token` neben der `.exe`). diff --git a/backend/external/agent_main.go b/backend/external/agent_main.go index 5291202..bdeac28 100644 --- a/backend/external/agent_main.go +++ b/backend/external/agent_main.go @@ -10,7 +10,19 @@ // DELETE /server-folders // PUT /agent-token (Token zur Laufzeit setzen / rotieren) // -// Start per Parameter (ohne Token): +// Start per JSON (empfohlen): +// server-folders-agent.json neben die .exe legen und server-folders-agent.exe starten. +// Alternativ: server-folders-agent.exe -config C:\Pfad\server-folders-agent.json +// +// Beispiel: +// { +// "listenAddr": ":8099", +// "storageRoots": [{ "label": "Storage D", "path": "D:\\Milestone\\Storage" }], +// "archiveRoots": [{ "label": "Archive E", "path": "E:\\Milestone\\Archive" }], +// "token": "" +// } +// +// Start per Parameter bleibt als Fallback möglich: // server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" // // Alternativ per ENV: @@ -19,11 +31,11 @@ // SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive // // Token-Handling: -// Der Agent wird ohne Token gestartet. Das Backend erzeugt beim Speichern der +// Der Agent kann ohne Token gestartet werden. Das Backend erzeugt beim Speichern der // Milestone-Einstellungen automatisch einen Token und schiebt ihn per -// PUT /agent-token an den Agent. Der Token wird in einer Datei persistiert -// (Standard: neben der .exe, siehe -token-file / SERVER_FOLDER_AGENT_TOKEN_FILE) -// und überlebt damit einen Neustart. +// PUT /agent-token an den Agent. Der Token wird in der JSON-Datei oder, wenn +// explizit konfiguriert, in einer Token-Datei persistiert und überlebt damit +// einen Neustart. // // Solange noch kein Token gesetzt ist, ist PUT /agent-token offen (Bootstrap). // Sobald ein Token gesetzt ist, muss der Client ihn senden: @@ -54,8 +66,9 @@ import ( ) const ( - defaultListenAddr = ":8099" - defaultTokenFileName = "server-folders-agent.token" + defaultListenAddr = ":8099" + defaultConfigFileName = "server-folders-agent.json" + defaultTokenFileName = "server-folders-agent.token" listenAddrEnv = "LISTEN_ADDR" serverStorageRootsEnv = "SERVER_STORAGE_ROOTS" @@ -63,6 +76,8 @@ const ( legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS" agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN" agentTokenFileEnv = "SERVER_FOLDER_AGENT_TOKEN_FILE" + agentConfigFileEnv = "SERVER_FOLDER_AGENT_CONFIG" + agentPortEnv = "SERVER_FOLDER_AGENT_PORT" ) type serverFolderRootType string @@ -73,11 +88,12 @@ const ( ) type appConfig struct { - ListenAddr string - StorageRoots string - ArchiveRoots string - Token string - TokenFile string + ListenAddr string + StorageRoots string + ArchiveRoots string + Token string + TokenFile string + TokenConfigFile string } func (config appConfig) rootsForType(rootType serverFolderRootType) string { @@ -88,6 +104,16 @@ func (config appConfig) rootsForType(rootType serverFolderRootType) string { return strings.TrimSpace(config.StorageRoots) } +type agentConfigFile struct { + ListenAddr string `json:"listenAddr,omitempty"` + Port string `json:"port,omitempty"` + Roots []serverFolderRoot `json:"roots,omitempty"` + StorageRoots []serverFolderRoot `json:"storageRoots,omitempty"` + ArchiveRoots []serverFolderRoot `json:"archiveRoots,omitempty"` + Token string `json:"token,omitempty"` + TokenFile string `json:"tokenFile,omitempty"` +} + type serverFolderRoot struct { Label string `json:"label"` Path string `json:"path"` @@ -121,7 +147,7 @@ type renameServerFolderRequest struct { func main() { config := readConfigFromFlagsAndEnv() - store := newTokenStore(config.TokenFile, config.Token) + store := newTokenStore(config) mux := http.NewServeMux() @@ -151,7 +177,13 @@ func main() { log.Printf("server-folders-agent listening on %s", config.ListenAddr) log.Printf("storage roots: %q", config.StorageRoots) log.Printf("archive roots: %q", config.ArchiveRoots) - log.Printf("token file: %s", config.TokenFile) + if config.TokenFile != "" { + log.Printf("token file: %s", config.TokenFile) + } else if config.TokenConfigFile != "" { + log.Printf("token config: %s", config.TokenConfigFile) + } else { + log.Printf("token persistence: disabled") + } log.Printf("token enabled: %t", store.get() != "") if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { @@ -160,21 +192,50 @@ func main() { } func readConfigFromFlagsAndEnv() appConfig { + configFlag := flag.String("config", "", "Path to JSON config file (default: server-folders-agent.json next to the executable)") addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099") portFlag := flag.String("port", "", "Listen port, e.g. 8099") legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone") storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage") archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive") tokenFlag := flag.String("token", "", "Optional start seed token (used only if no token file exists yet)") - tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token (default: next to the executable)") + tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token instead of writing it to the JSON config") flag.Parse() - listenAddr := strings.TrimSpace(*addrFlag) - if listenAddr == "" { - listenAddr = strings.TrimSpace(os.Getenv(listenAddrEnv)) + config := appConfig{} + configPath := strings.TrimSpace(*configFlag) + if configPath == "" { + configPath = strings.TrimSpace(os.Getenv(agentConfigFileEnv)) } + explicitConfigPath := configPath != "" + if configPath == "" { + configPath = defaultConfigFilePath() + } + + loadedConfigFile := false + if explicitConfigPath || fileExists(configPath) { + fileConfig, err := readAgentConfigFile(configPath) + if err != nil { + log.Fatalf("config file konnte nicht gelesen werden: %v", err) + } + + applyAgentConfigFile(&config, fileConfig) + config.TokenConfigFile = configPath + loadedConfigFile = true + } + + listenAddr := firstNonEmpty( + *addrFlag, + os.Getenv(listenAddrEnv), + config.ListenAddr, + ) + port := strings.TrimSpace(*portFlag) + if listenAddr == "" && port == "" { + port = strings.TrimSpace(os.Getenv(agentPortEnv)) + } + if listenAddr == "" && port != "" { port = strings.TrimPrefix(port, ":") listenAddr = ":" + port @@ -184,49 +245,157 @@ func readConfigFromFlagsAndEnv() appConfig { listenAddr = defaultListenAddr } - legacyRoots := strings.TrimSpace(*legacyRootsFlag) - if legacyRoots == "" { - legacyRoots = strings.TrimSpace(os.Getenv(legacyFolderRootsEnv)) - } + legacyRoots := firstNonEmpty(*legacyRootsFlag, os.Getenv(legacyFolderRootsEnv)) - storageRoots := strings.TrimSpace(*storageRootsFlag) - if storageRoots == "" { - storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv)) - } + storageRoots := firstNonEmpty( + *storageRootsFlag, + os.Getenv(serverStorageRootsEnv), + config.StorageRoots, + ) if storageRoots == "" { storageRoots = legacyRoots } - archiveRoots := strings.TrimSpace(*archiveRootsFlag) - if archiveRoots == "" { - archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv)) - } + archiveRoots := firstNonEmpty( + *archiveRootsFlag, + os.Getenv(serverArchiveRootsEnv), + config.ArchiveRoots, + ) if archiveRoots == "" { archiveRoots = legacyRoots } - token := strings.TrimSpace(*tokenFlag) - if token == "" { - token = strings.TrimSpace(os.Getenv(agentTokenEnv)) - } + token := firstNonEmpty(*tokenFlag, os.Getenv(agentTokenEnv), config.Token) - tokenFile := strings.TrimSpace(*tokenFileFlag) + tokenFile := firstNonEmpty(*tokenFileFlag, os.Getenv(agentTokenFileEnv), config.TokenFile) if tokenFile == "" { - tokenFile = strings.TrimSpace(os.Getenv(agentTokenFileEnv)) - } - if tokenFile == "" { - tokenFile = defaultTokenFilePath() + if loadedConfigFile { + config.TokenConfigFile = configPath + } else { + tokenFile = defaultTokenFilePath() + } + } else { + config.TokenConfigFile = "" } return appConfig{ - ListenAddr: listenAddr, - StorageRoots: storageRoots, - ArchiveRoots: archiveRoots, - Token: token, - TokenFile: tokenFile, + ListenAddr: listenAddr, + StorageRoots: storageRoots, + ArchiveRoots: archiveRoots, + Token: token, + TokenFile: tokenFile, + TokenConfigFile: config.TokenConfigFile, } } +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value = strings.TrimSpace(value); value != "" { + return value + } + } + + return "" +} + +func readAgentConfigFile(pathValue string) (agentConfigFile, error) { + var config agentConfigFile + + data, err := os.ReadFile(pathValue) + if err != nil { + return config, err + } + + if err := json.Unmarshal(data, &config); err != nil { + return config, err + } + + return config, nil +} + +func updateAgentConfigFileToken(pathValue string, token string) error { + data, err := os.ReadFile(pathValue) + if err != nil { + return err + } + + values := map[string]any{} + if strings.TrimSpace(string(data)) != "" { + if err := json.Unmarshal(data, &values); err != nil { + return err + } + } + + values["token"] = strings.TrimSpace(token) + + nextData, err := json.MarshalIndent(values, "", " ") + if err != nil { + return err + } + + nextData = append(nextData, '\n') + return os.WriteFile(pathValue, nextData, 0600) +} + +func applyAgentConfigFile(config *appConfig, fileConfig agentConfigFile) { + config.ListenAddr = strings.TrimSpace(fileConfig.ListenAddr) + if config.ListenAddr == "" && strings.TrimSpace(fileConfig.Port) != "" { + config.ListenAddr = ":" + strings.TrimPrefix(strings.TrimSpace(fileConfig.Port), ":") + } + + legacyRoots := formatServerFolderRoots(fileConfig.Roots) + config.StorageRoots = formatServerFolderRoots(fileConfig.StorageRoots) + if config.StorageRoots == "" { + config.StorageRoots = legacyRoots + } + + config.ArchiveRoots = formatServerFolderRoots(fileConfig.ArchiveRoots) + if config.ArchiveRoots == "" { + config.ArchiveRoots = legacyRoots + } + + config.Token = strings.TrimSpace(fileConfig.Token) + config.TokenFile = strings.TrimSpace(fileConfig.TokenFile) +} + +func formatServerFolderRoots(roots []serverFolderRoot) string { + parts := make([]string, 0, len(roots)) + + for _, root := range roots { + pathValue := strings.TrimSpace(root.Path) + if pathValue == "" { + continue + } + + label := strings.TrimSpace(root.Label) + if label == "" { + parts = append(parts, pathValue) + continue + } + + parts = append(parts, label+"="+pathValue) + } + + return strings.Join(parts, ";") +} + +func fileExists(pathValue string) bool { + if strings.TrimSpace(pathValue) == "" { + return false + } + + info, err := os.Stat(pathValue) + return err == nil && !info.IsDir() +} + +func defaultConfigFilePath() string { + if exePath, err := os.Executable(); err == nil { + return filepath.Join(filepath.Dir(exePath), defaultConfigFileName) + } + + return defaultConfigFileName +} + func defaultTokenFilePath() string { if exePath, err := os.Executable(); err == nil { return filepath.Join(filepath.Dir(exePath), defaultTokenFileName) @@ -810,23 +979,27 @@ func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot) return false } -// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in einer -// Datei persistiert, damit er einen Neustart des Agents übersteht. +// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in der +// JSON-Config oder einer separaten Datei persistiert. type tokenStore struct { - mu sync.RWMutex - token string - filePath string + mu sync.RWMutex + token string + filePath string + configFilePath string } -func newTokenStore(filePath string, seed string) *tokenStore { - store := &tokenStore{filePath: strings.TrimSpace(filePath)} +func newTokenStore(config appConfig) *tokenStore { + store := &tokenStore{ + filePath: strings.TrimSpace(config.TokenFile), + configFilePath: strings.TrimSpace(config.TokenConfigFile), + } if persisted := store.loadFromFile(); persisted != "" { store.token = persisted return store } - if seed = strings.TrimSpace(seed); seed != "" { + if seed := strings.TrimSpace(config.Token); seed != "" { store.token = seed if err := store.persist(seed); err != nil { @@ -860,24 +1033,37 @@ func (t *tokenStore) set(token string) error { } func (t *tokenStore) loadFromFile() string { - if t.filePath == "" { + if t.filePath != "" { + data, err := os.ReadFile(t.filePath) + if err != nil { + return "" + } + + return strings.TrimSpace(string(data)) + } + + if t.configFilePath == "" { return "" } - data, err := os.ReadFile(t.filePath) + config, err := readAgentConfigFile(t.configFilePath) if err != nil { return "" } - return strings.TrimSpace(string(data)) + return strings.TrimSpace(config.Token) } func (t *tokenStore) persist(token string) error { - if t.filePath == "" { - return nil + if t.filePath != "" { + return os.WriteFile(t.filePath, []byte(token), 0600) } - return os.WriteFile(t.filePath, []byte(token), 0600) + if t.configFilePath != "" { + return updateAgentConfigFileToken(t.configFilePath, token) + } + + return nil } type setAgentTokenRequest struct { diff --git a/backend/history.go b/backend/history.go index 6639836..2cb3411 100644 --- a/backend/history.go +++ b/backend/history.go @@ -18,6 +18,7 @@ type DeviceHistoryEntry struct { DeviceID string `json:"deviceId"` UserID string `json:"userId"` UserDisplayName string `json:"userDisplayName"` + UserAvatar string `json:"userAvatar"` Action string `json:"action"` Changes []DeviceHistoryChange `json:"changes"` CreatedAt time.Time `json:"createdAt"` @@ -58,6 +59,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) h.device_id, COALESCE(h.user_id::TEXT, ''), COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), + COALESCE(u.avatar, ''), h.action, h.changes, h.created_at, @@ -87,6 +89,7 @@ func (s *Server) handleListDeviceHistory(w http.ResponseWriter, r *http.Request) &entry.DeviceID, &entry.UserID, &entry.UserDisplayName, + &entry.UserAvatar, &entry.Action, &changesRaw, &entry.CreatedAt, @@ -248,27 +251,35 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic ctx, ` SELECT - id, - inventory_number, - manufacturer, - model, - serial_number, - mac_address, - COALESCE(ip_address, ''), - COALESCE(firmware_version, ''), - COALESCE(milestone_recording_server_id, ''), - COALESCE(milestone_hardware_id, ''), - COALESCE(milestone_display_name, ''), - milestone_enabled, - milestone_synced_at, - location, - loan_status, - is_bao_device, - comment, - created_at, - updated_at + devices.id, + COALESCE(devices.inventory_number, ''), + COALESCE(devices.manufacturer, ''), + COALESCE(devices.model, ''), + COALESCE(devices.serial_number, ''), + COALESCE(devices.mac_address, ''), + COALESCE(devices.ip_address, ''), + COALESCE(devices.phone_number, ''), + COALESCE(devices.computer_name, ''), + COALESCE(devices.device_category, ''), + COALESCE(devices.firmware_version, ''), + COALESCE(devices.milestone_recording_server_id, ''), + COALESCE(devices.milestone_hardware_id, ''), + COALESCE(devices.milestone_display_name, ''), + COALESCE(devices.milestone_enabled, true), + devices.milestone_synced_at, + COALESCE(devices.location, ''), + COALESCE(devices.loan_status, 'Verfügbar'), + COALESCE(devices.loaned_to_user_id::TEXT, ''), + COALESCE(NULLIF(loaned_user.display_name, ''), NULLIF(loaned_user.username, ''), loaned_user.email, ''), + COALESCE(devices.loaned_until::TEXT, ''), + COALESCE(devices.is_bao_device, false), + COALESCE(devices.comment, ''), + devices.created_at, + devices.updated_at FROM devices - WHERE id = $1 + LEFT JOIN users AS loaned_user + ON loaned_user.id = devices.loaned_to_user_id + WHERE devices.id = $1 LIMIT 1 `, deviceID, @@ -280,6 +291,9 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic &device.SerialNumber, &device.MacAddress, &device.IPAddress, + &device.PhoneNumber, + &device.ComputerName, + &device.DeviceCategory, &device.FirmwareVersion, &device.MilestoneRecordingServerID, &device.MilestoneHardwareID, @@ -288,6 +302,9 @@ func getDeviceForHistory(ctx context.Context, tx pgx.Tx, deviceID string) (Devic &device.MilestoneSyncedAt, &device.Location, &device.LoanStatus, + &device.LoanedToUserID, + &device.LoanedToDisplayName, + &device.LoanedUntil, &device.IsBaoDevice, &device.Comment, &device.CreatedAt, @@ -537,8 +554,17 @@ func buildDeviceCreateHistoryChanges( changes = appendHistoryChange(changes, "model", "Modell", "", input.Model) changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", "", input.SerialNumber) changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", "", input.MacAddress) + changes = appendHistoryChange(changes, "phoneNumber", "Rufnummer", "", input.PhoneNumber) + changes = appendHistoryChange(changes, "computerName", "Computername", "", input.ComputerName) + changes = appendHistoryChange(changes, "deviceCategory", "Kategorie", "", input.DeviceCategory) changes = appendHistoryChange(changes, "location", "Standort", "", input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", "", input.LoanStatus) + loanedToUser, err := formatLoanedUserLabel(ctx, tx, input.LoanedToUserID) + if err != nil { + return nil, err + } + changes = appendHistoryChange(changes, "loanedToUserId", "Verliehen an", "", loanedToUser) + changes = appendHistoryChange(changes, "loanedUntil", "Verliehen bis", "", input.LoanedUntil) changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", "Nein", formatHistoryBool(input.IsBaoDevice)) changes = appendHistoryChange(changes, "comment", "Kommentar", "", input.Comment) @@ -566,8 +592,17 @@ func buildDeviceUpdateHistoryChanges( changes = appendHistoryChange(changes, "model", "Modell", oldDevice.Model, input.Model) changes = appendHistoryChange(changes, "serialNumber", "Seriennummer", oldDevice.SerialNumber, input.SerialNumber) changes = appendHistoryChange(changes, "macAddress", "MAC-Adresse", oldDevice.MacAddress, input.MacAddress) + changes = appendHistoryChange(changes, "phoneNumber", "Rufnummer", oldDevice.PhoneNumber, input.PhoneNumber) + changes = appendHistoryChange(changes, "computerName", "Computername", oldDevice.ComputerName, input.ComputerName) + changes = appendHistoryChange(changes, "deviceCategory", "Kategorie", oldDevice.DeviceCategory, input.DeviceCategory) changes = appendHistoryChange(changes, "location", "Standort", oldDevice.Location, input.Location) changes = appendHistoryChange(changes, "loanStatus", "Verleih-Status", oldDevice.LoanStatus, input.LoanStatus) + loanedToUser, err := formatLoanedUserLabel(ctx, tx, input.LoanedToUserID) + if err != nil { + return nil, err + } + changes = appendHistoryChange(changes, "loanedToUserId", "Verliehen an", oldDevice.LoanedToDisplayName, loanedToUser) + changes = appendHistoryChange(changes, "loanedUntil", "Verliehen bis", oldDevice.LoanedUntil, input.LoanedUntil) changes = appendHistoryChange(changes, "isBaoDevice", "BAO-Gerät", formatHistoryBool(oldDevice.IsBaoDevice), formatHistoryBool(input.IsBaoDevice)) changes = appendHistoryChange(changes, "comment", "Kommentar", oldDevice.Comment, input.Comment) changes = appendHistoryChange(changes, "milestoneDisplayName", "Milestone-Anzeigename", oldDevice.MilestoneDisplayName, input.MilestoneDisplayName) @@ -678,6 +713,35 @@ func formatRelatedDeviceLabels(ctx context.Context, tx pgx.Tx, deviceIDs []strin return strings.Join(labels, ", "), nil } +func formatLoanedUserLabel(ctx context.Context, tx pgx.Tx, userID string) (string, error) { + userID = strings.TrimSpace(userID) + if userID == "" { + return "", nil + } + + var label string + err := tx.QueryRow( + ctx, + ` + SELECT COALESCE(NULLIF(display_name, ''), NULLIF(username, ''), email, '') + FROM users + WHERE id = $1 + LIMIT 1 + `, + userID, + ).Scan(&label) + + if errors.Is(err, pgx.ErrNoRows) { + return userID, nil + } + + if err != nil { + return "", err + } + + return label, nil +} + func normalizeDeviceIDs(deviceIDs []string) []string { seen := map[string]bool{} result := []string{} diff --git a/backend/main.go b/backend/main.go index 8c77a7c..69537c0 100644 --- a/backend/main.go +++ b/backend/main.go @@ -36,6 +36,10 @@ func main() { log.Fatal(err) } + if err := ensureRuntimeDeviceSchema(ctx, db); err != nil { + log.Fatal(err) + } + server := NewServer(db, jwtSecret, frontendOrigin) server.startPresenceMonitor(ctx) server.startDisappearingMessagesMonitor(ctx) diff --git a/backend/milestone_cameras.go b/backend/milestone_cameras.go index 57ec80f..1dffb38 100644 --- a/backend/milestone_cameras.go +++ b/backend/milestone_cameras.go @@ -3,6 +3,7 @@ package main import ( + "bytes" "context" "encoding/json" "errors" @@ -10,6 +11,8 @@ import ( "io" "log" "net/http" + "net/url" + "regexp" "strings" "github.com/jackc/pgx/v5" @@ -22,6 +25,8 @@ type milestoneCameraPatchDevice struct { MilestoneHardwareID string MilestoneDeviceID string DeviceType string + IPAddress string + MilestonePort string Enabled bool RecordingEnabled bool } @@ -35,13 +40,14 @@ type milestoneRecordingStorageRef struct { } type milestoneCameraStream struct { - Index int `json:"index"` - Codec string `json:"codec"` - FPS *int `json:"fps"` - JPEGQuality *int `json:"jPEGQuality"` - Quality *int `json:"quality"` - Resolution string `json:"resolution"` - EdgeStorageSupported bool `json:"edgeStorageSupported"` + Index int `json:"index"` + Codec string `json:"codec"` + FPS *int `json:"fps"` + JPEGQuality *int `json:"jPEGQuality"` + Quality *int `json:"quality"` + Resolution string `json:"resolution"` + AvailableResolutions []string `json:"availableResolutions,omitempty"` + EdgeStorageSupported bool `json:"edgeStorageSupported"` } type milestoneCameraDetail struct { @@ -75,6 +81,119 @@ type updateMilestoneCameraDetailsRequest struct { Streams []updateMilestoneCameraStreamRequest `json:"streams"` } +var milestoneResolutionPattern = regexp.MustCompile(`(?i)(\d{2,5})\s*(?:x|\x{00D7})\s*(\d{2,5})`) + +var milestoneResolutionCandidateKeys = []string{ + "availableResolutions", + "availableResolution", + "supportedResolutions", + "supportedResolution", + "resolutionOptions", + "resolutionValues", + "resolutionChoices", + "allowedResolutions", + "allowedResolution", + "resolutions", + "videoResolutions", +} + +func normalizeMilestoneResolution(value any) string { + text := strings.TrimSpace(milestoneStringValue(value)) + if text == "" { + return "" + } + + match := milestoneResolutionPattern.FindStringSubmatch(text) + if len(match) >= 3 { + return match[1] + "x" + match[2] + } + + return "" +} + +func extractMilestoneResolutionValues(value any, depth int) []string { + if depth > 4 || value == nil { + return nil + } + + switch typedValue := value.(type) { + case string: + values := []string{} + for _, part := range strings.FieldsFunc(typedValue, func(r rune) bool { + return r == ',' || r == ';' || r == '|' + }) { + if resolution := normalizeMilestoneResolution(part); resolution != "" { + values = append(values, resolution) + } + } + return values + case float64, int, int64: + if resolution := normalizeMilestoneResolution(typedValue); resolution != "" { + return []string{resolution} + } + return nil + case []any: + values := []string{} + for _, item := range typedValue { + values = append(values, extractMilestoneResolutionValues(item, depth+1)...) + } + return values + case map[string]any: + values := []string{} + for _, key := range []string{"value", "name", "displayName", "label", "text", "id", "resolution"} { + values = append(values, extractMilestoneResolutionValues(typedValue[key], depth+1)...) + } + for _, key := range []string{"values", "options", "items", "choices", "allowedValues", "allowed", "array"} { + values = append(values, extractMilestoneResolutionValues(typedValue[key], depth+1)...) + } + for key := range typedValue { + if resolution := normalizeMilestoneResolution(key); resolution != "" { + values = append(values, resolution) + } + } + return values + default: + if resolution := normalizeMilestoneResolution(typedValue); resolution != "" { + return []string{resolution} + } + return nil + } +} + +func milestoneAvailableResolutions(streamMap map[string]any, currentResolution string) []string { + seen := map[string]struct{}{} + values := []string{} + + add := func(candidates ...string) { + for _, candidate := range candidates { + resolution := normalizeMilestoneResolution(candidate) + if resolution == "" { + continue + } + if _, exists := seen[resolution]; exists { + continue + } + seen[resolution] = struct{}{} + values = append(values, resolution) + } + } + + for _, key := range milestoneResolutionCandidateKeys { + for _, resolution := range extractMilestoneResolutionValues(streamMap[key], 0) { + add(resolution) + } + } + + if _, ok := streamMap["resolution"].(map[string]any); ok { + for _, resolution := range extractMilestoneResolutionValues(streamMap["resolution"], 0) { + add(resolution) + } + } + + add(currentResolution) + return values +} + func (s *Server) getMilestoneCameraPatchDevice( ctx context.Context, deviceID string, @@ -92,6 +211,8 @@ func (s *Server) getMilestoneCameraPatchDevice( milestone_hardware_child_devices.milestone_hardware_id, milestone_hardware_child_devices.milestone_device_id, milestone_hardware_child_devices.device_type, + COALESCE(devices.ip_address, ''), + COALESCE(devices.milestone_port, ''), milestone_hardware_child_devices.enabled, COALESCE(milestone_hardware_child_devices.recording_enabled, false) FROM milestone_hardware_child_devices @@ -110,6 +231,8 @@ func (s *Server) getMilestoneCameraPatchDevice( &camera.MilestoneHardwareID, &camera.MilestoneDeviceID, &camera.DeviceType, + &camera.IPAddress, + &camera.MilestonePort, &camera.Enabled, &camera.RecordingEnabled, ) @@ -382,6 +505,10 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS Resolution: milestoneStringValue(streamMap["resolution"]), EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]), } + stream.AvailableResolutions = milestoneAvailableResolutions( + streamMap, + stream.Resolution, + ) if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok { stream.FPS = value @@ -404,6 +531,8 @@ func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraS type milestoneCameraListItem struct { ID string `json:"id"` + LocalDeviceID string `json:"localDeviceId,omitempty"` + LocalChildID string `json:"localChildId,omitempty"` Name string `json:"name"` DisplayName string `json:"displayName"` Type string `json:"type"` @@ -414,6 +543,17 @@ type milestoneCameraListItem struct { HardwareDisplayName string `json:"hardwareDisplayName"` } +type milestoneLocalDeviceRef struct { + DeviceID string + ChildID string +} + +func milestoneLocalDeviceRefKey(hardwareID string, deviceType string, milestoneDeviceID string) string { + return strings.TrimSpace(hardwareID) + "|" + + strings.TrimSpace(strings.ToLower(deviceType)) + "|" + + strings.TrimSpace(milestoneDeviceID) +} + func (s *Server) getMilestoneHardwareDisplayNameMap( ctx context.Context, ) (map[string]string, error) { @@ -461,11 +601,77 @@ func (s *Server) getMilestoneHardwareDisplayNameMap( return out, nil } +func (s *Server) getMilestoneLocalDeviceRefMap( + ctx context.Context, +) (map[string]milestoneLocalDeviceRef, error) { + rows, err := s.db.Query( + ctx, + ` + SELECT + devices.id::TEXT, + milestone_hardware_child_devices.id::TEXT, + milestone_hardware_child_devices.milestone_hardware_id, + milestone_hardware_child_devices.device_type, + milestone_hardware_child_devices.milestone_device_id + FROM milestone_hardware_child_devices + INNER JOIN devices + ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id + WHERE COALESCE(devices.milestone_hardware_id, '') <> '' + `, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + out := map[string]milestoneLocalDeviceRef{} + + for rows.Next() { + var deviceID string + var childID string + var hardwareID string + var deviceType string + var milestoneDeviceID string + + if err := rows.Scan( + &deviceID, + &childID, + &hardwareID, + &deviceType, + &milestoneDeviceID, + ); err != nil { + return nil, err + } + + deviceID = strings.TrimSpace(deviceID) + childID = strings.TrimSpace(childID) + hardwareID = strings.TrimSpace(hardwareID) + deviceType = strings.TrimSpace(strings.ToLower(deviceType)) + milestoneDeviceID = strings.TrimSpace(milestoneDeviceID) + + if deviceID == "" || childID == "" || hardwareID == "" || milestoneDeviceID == "" { + continue + } + + out[milestoneLocalDeviceRefKey(hardwareID, deviceType, milestoneDeviceID)] = milestoneLocalDeviceRef{ + DeviceID: deviceID, + ChildID: childID, + } + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return out, nil +} + func getMilestoneDeviceList( ctx context.Context, config milestoneRuntimeConfig, collection string, hardwareDisplayNames map[string]string, + localDeviceRefs map[string]milestoneLocalDeviceRef, ) ([]milestoneCameraListItem, error) { requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/" + collection + "?disabled" @@ -557,9 +763,12 @@ func getMilestoneDeviceList( hardwareID := milestoneNamedDeviceParentID(item) hardwareDisplayName := strings.TrimSpace(hardwareDisplayNames[hardwareID]) + localDeviceRef := localDeviceRefs[milestoneLocalDeviceRefKey(hardwareID, deviceType, id)] items = append(items, milestoneCameraListItem{ ID: id, + LocalDeviceID: localDeviceRef.DeviceID, + LocalChildID: localDeviceRef.ChildID, Name: name, DisplayName: displayName, Type: deviceType, @@ -587,14 +796,20 @@ func (s *Server) handleListMilestoneCameras(w http.ResponseWriter, r *http.Reque return } - cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames) + localDeviceRefs, err := s.getMilestoneLocalDeviceRefMap(r.Context()) + if err != nil { + writeError(w, http.StatusInternalServerError, "Lokale Milestone-Gerätezuordnung konnte nicht geladen werden") + return + } + + cameras, err := getMilestoneDeviceList(r.Context(), config, "cameras", hardwareDisplayNames, localDeviceRefs) if err != nil { log.Printf("Milestone cameras list failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Kameras konnten nicht geladen werden") return } - microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames) + microphones, err := getMilestoneDeviceList(r.Context(), config, "microphones", hardwareDisplayNames, localDeviceRefs) if err != nil { log.Printf("Milestone microphones list failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Mikrofone konnten nicht geladen werden") @@ -718,3 +933,345 @@ func applyMilestoneCameraStreamUpdates( } } } + +func getMilestoneApiCameraStreams( + ctx context.Context, + config milestoneRuntimeConfig, + milestoneDeviceID string, +) ([]any, error) { + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/cameras/" + url.PathEscape(milestoneDeviceID) + "/streams" + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("milestone camera streams get failed: status=%d", response.StatusCode) + } + + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, err + } + + if array, ok := decoded["array"].([]any); ok { + return array, nil + } + + return []any{}, nil +} + +func putMilestoneApiStream( + ctx context.Context, + config milestoneRuntimeConfig, + streamID string, + payload map[string]any, +) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/streams/" + url.PathEscape(streamID) + + request, err := http.NewRequestWithContext(ctx, http.MethodPut, requestURL, bytes.NewReader(body)) + if err != nil { + return err + } + + setMilestoneRequestHeaders(request, config, true) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("milestone stream put failed: status=%d body=%s", response.StatusCode, string(responseBody)) + } + + return nil +} + +func (s *Server) handleGetMilestoneCameraApiStreams(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + childID := strings.TrimSpace(r.PathValue("childId")) + + if deviceID == "" || childID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt") + return + } + + cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + streams, err := getMilestoneApiCameraStreams(r.Context(), config, cameraDevice.MilestoneDeviceID) + if err != nil { + log.Printf("Milestone camera API streams failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Streams konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "streams": streams, + }) +} + +func (s *Server) handleUpdateMilestoneApiStream(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + childID := strings.TrimSpace(r.PathValue("childId")) + streamID := strings.TrimSpace(r.PathValue("streamId")) + + if deviceID == "" || childID == "" || streamID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID, Child-ID oder Stream-ID fehlt") + return + } + + _, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden") + return + } + + var payload map[string]any + if err := readJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + if err := putMilestoneApiStream(r.Context(), config, streamID, payload); err != nil { + log.Printf("Milestone stream put failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Stream konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func getMilestoneDeviceSettings( + ctx context.Context, + config milestoneRuntimeConfig, + milestoneDeviceID string, +) (map[string]any, error) { + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/settings/" + url.PathEscape(milestoneDeviceID) + + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, false) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("milestone settings get failed: status=%d", response.StatusCode) + } + + var envelope struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err != nil { + return nil, err + } + + if envelope.Data == nil { + return map[string]any{}, nil + } + + return envelope.Data, nil +} + +func patchMilestoneDeviceSettings( + ctx context.Context, + config milestoneRuntimeConfig, + settingsID string, + payload map[string]any, +) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/settings/" + url.PathEscape(settingsID) + + request, err := http.NewRequestWithContext(ctx, http.MethodPatch, requestURL, bytes.NewReader(body)) + if err != nil { + return err + } + + setMilestoneRequestHeaders(request, config, true) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf("milestone settings patch failed: status=%d body=%s", response.StatusCode, string(responseBody)) + } + + return nil +} + +func (s *Server) handleGetMilestoneCameraSettingsDetail(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + childID := strings.TrimSpace(r.PathValue("childId")) + + if deviceID == "" || childID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt") + return + } + + cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + settings, err := getMilestoneDeviceSettings(r.Context(), config, cameraDevice.MilestoneDeviceID) + if err != nil { + log.Printf("Milestone camera settings get failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Einstellungen konnten nicht geladen werden") + return + } + + activeStreams, err := getMilestoneApiCameraStreams(r.Context(), config, cameraDevice.MilestoneDeviceID) + if err != nil { + log.Printf("Milestone camera active streams get failed: %v", err) + activeStreams = []any{} + } + + onvifResolutions, err := s.getCachedOnvifCameraResolutions( + r.Context(), + cameraDevice, + config.SkipTLSVerify, + ) + if err != nil { + log.Printf( + "ONVIF camera resolutions could not be loaded: hardwareId=%s deviceId=%s error=%v", + cameraDevice.MilestoneHardwareID, + cameraDevice.MilestoneDeviceID, + err, + ) + } + if len(onvifResolutions) > 0 { + applyOnvifAvailableResolutionsToMilestoneSettings(settings, onvifResolutions) + applyOnvifAvailableResolutionsToMilestoneActiveStreams(activeStreams, onvifResolutions) + } + + writeJSON(w, http.StatusOK, map[string]any{ + "settings": settings, + "activeStreams": activeStreams, + }) +} + +func (s *Server) handleUpdateMilestoneCameraSettingsDetail(w http.ResponseWriter, r *http.Request) { + deviceID := strings.TrimSpace(r.PathValue("id")) + childID := strings.TrimSpace(r.PathValue("childId")) + + if deviceID == "" || childID == "" { + writeError(w, http.StatusBadRequest, "Geräte-ID oder Child-ID fehlt") + return + } + + cameraDevice, err := s.getMilestoneCameraPatchDevice(r.Context(), deviceID, childID) + if errors.Is(err, pgx.ErrNoRows) { + writeError(w, http.StatusNotFound, "Milestone-Kamera wurde nicht gefunden") + return + } + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte nicht geladen werden") + return + } + + var payload map[string]any + if err := readJSON(r, &payload); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + if err := patchMilestoneDeviceSettings(r.Context(), config, cameraDevice.MilestoneDeviceID, payload); err != nil { + log.Printf("Milestone camera settings patch failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Einstellungen konnten nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} diff --git a/backend/milestone_sync.go b/backend/milestone_sync.go index 2d02d32..dfb6f62 100644 --- a/backend/milestone_sync.go +++ b/backend/milestone_sync.go @@ -1038,6 +1038,7 @@ func createOrUpdateMilestoneHardwareDevice( serial_number, mac_address, ip_address, + device_category, milestone_port, location, loan_status, @@ -1057,6 +1058,7 @@ func createOrUpdateMilestoneHardwareDevice( $4, $5, $6, + 'Kameras', $7, '', 'Verfügbar', @@ -1078,6 +1080,7 @@ func createOrUpdateMilestoneHardwareDevice( serial_number = EXCLUDED.serial_number, mac_address = EXCLUDED.mac_address, ip_address = EXCLUDED.ip_address, + device_category = 'Kameras', milestone_port = EXCLUDED.milestone_port, firmware_version = EXCLUDED.firmware_version, milestone_recording_server_id = EXCLUDED.milestone_recording_server_id, diff --git a/backend/onvif_resolutions.go b/backend/onvif_resolutions.go new file mode 100644 index 0000000..a3de6e0 --- /dev/null +++ b/backend/onvif_resolutions.go @@ -0,0 +1,926 @@ +// backend\onvif_resolutions.go + +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha1" + "crypto/tls" + "encoding/base64" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "io" + "log" + "net" + "net/http" + "net/url" + "os" + "sort" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +const onvifResolutionCacheMaxAge = 24 * time.Hour +const onvifResolutionFetchTimeout = 7 * time.Second + +type onvifResolutionCacheEntry struct { + Resolutions []string + CheckedAt time.Time + CheckError string +} + +type onvifCameraConnection struct { + Host string + Port string + Username string + Password string + SkipTLSVerify bool +} + +func (s *Server) getCachedOnvifCameraResolutions( + ctx context.Context, + cameraDevice milestoneCameraPatchDevice, + skipTLSVerify bool, +) ([]string, error) { + host := strings.TrimSpace(cameraDevice.IPAddress) + port := strings.TrimSpace(cameraDevice.MilestonePort) + if host == "" { + return nil, nil + } + + if port == "" { + port = "80" + } + + hardwareID := strings.TrimSpace(cameraDevice.MilestoneHardwareID) + cacheReady := hardwareID != "" + if cacheReady { + if err := s.ensureOnvifResolutionCacheTable(ctx); err != nil { + cacheReady = false + log.Printf("ONVIF resolution cache table could not be ensured: %v", err) + } + } + + var cachedEntry onvifResolutionCacheEntry + var cached bool + if cacheReady { + entry, ok, err := s.getOnvifResolutionCache( + ctx, + hardwareID, + host, + port, + ) + if err != nil { + log.Printf("ONVIF resolution cache could not be read: %v", err) + } else { + cachedEntry = entry + cached = ok + } + } + + if cached && + len(cachedEntry.Resolutions) > 0 && + time.Since(cachedEntry.CheckedAt) <= onvifResolutionCacheMaxAge { + return cachedEntry.Resolutions, nil + } + + username, password := s.onvifCredentials(ctx) + fetchCtx, cancel := context.WithTimeout(ctx, onvifResolutionFetchTimeout) + defer cancel() + + resolutions, err := fetchOnvifVideoEncoderResolutions(fetchCtx, onvifCameraConnection{ + Host: host, + Port: port, + Username: username, + Password: password, + SkipTLSVerify: skipTLSVerify, + }) + if err != nil { + if cached && len(cachedEntry.Resolutions) > 0 { + return cachedEntry.Resolutions, nil + } + + if cacheReady { + if cacheErr := s.upsertOnvifResolutionCache( + ctx, + hardwareID, + host, + port, + nil, + err.Error(), + ); cacheErr != nil { + log.Printf("ONVIF resolution cache error could not be stored: %v", cacheErr) + } + } + + return nil, err + } + + if cacheReady { + if err := s.upsertOnvifResolutionCache( + ctx, + hardwareID, + host, + port, + resolutions, + "", + ); err != nil { + log.Printf("ONVIF resolution cache could not be stored: %v", err) + } + } + + return resolutions, nil +} + +func (s *Server) onvifCredentials(ctx context.Context) (string, string) { + envUsername := strings.TrimSpace(os.Getenv("ONVIF_USERNAME")) + envPassword := strings.TrimSpace(os.Getenv("ONVIF_PASSWORD")) + if envUsername != "" || envPassword != "" { + return envUsername, envPassword + } + + credentials, err := s.getMilestoneCredentials(ctx) + if err != nil { + return "", "" + } + + return strings.TrimSpace(credentials.Username), strings.TrimSpace(credentials.Password) +} + +func (s *Server) ensureOnvifResolutionCacheTable(ctx context.Context) error { + _, err := s.db.Exec( + ctx, + ` + CREATE TABLE IF NOT EXISTS onvif_resolution_cache ( + milestone_hardware_id TEXT PRIMARY KEY, + host TEXT NOT NULL DEFAULT '', + port TEXT NOT NULL DEFAULT '', + resolutions JSONB NOT NULL DEFAULT '[]'::JSONB, + check_error TEXT NOT NULL DEFAULT '', + checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + ) + return err +} + +func (s *Server) getOnvifResolutionCache( + ctx context.Context, + hardwareID string, + host string, + port string, +) (onvifResolutionCacheEntry, bool, error) { + var entry onvifResolutionCacheEntry + var rawResolutions []byte + + err := s.db.QueryRow( + ctx, + ` + SELECT resolutions, checked_at, check_error + FROM onvif_resolution_cache + WHERE milestone_hardware_id = $1 + AND host = $2 + AND port = $3 + LIMIT 1 + `, + strings.TrimSpace(hardwareID), + strings.TrimSpace(host), + strings.TrimSpace(port), + ).Scan( + &rawResolutions, + &entry.CheckedAt, + &entry.CheckError, + ) + if errors.Is(err, pgxErrNoRows()) { + return entry, false, nil + } + if err != nil { + return entry, false, err + } + + if err := json.Unmarshal(rawResolutions, &entry.Resolutions); err != nil { + return entry, false, err + } + + entry.Resolutions = sortMilestoneResolutionValues(entry.Resolutions) + + return entry, true, nil +} + +func (s *Server) upsertOnvifResolutionCache( + ctx context.Context, + hardwareID string, + host string, + port string, + resolutions []string, + checkError string, +) error { + resolutions = sortMilestoneResolutionValues(resolutions) + + rawResolutions, err := json.Marshal(resolutions) + if err != nil { + return err + } + + _, err = s.db.Exec( + ctx, + ` + INSERT INTO onvif_resolution_cache ( + milestone_hardware_id, + host, + port, + resolutions, + check_error, + checked_at, + updated_at + ) + VALUES ($1, $2, $3, $4::JSONB, $5, now(), now()) + ON CONFLICT (milestone_hardware_id) + DO UPDATE SET + host = EXCLUDED.host, + port = EXCLUDED.port, + resolutions = EXCLUDED.resolutions, + check_error = EXCLUDED.check_error, + checked_at = EXCLUDED.checked_at, + updated_at = now() + `, + strings.TrimSpace(hardwareID), + strings.TrimSpace(host), + strings.TrimSpace(port), + string(rawResolutions), + strings.TrimSpace(checkError), + ) + return err +} + +func fetchOnvifVideoEncoderResolutions( + ctx context.Context, + connection onvifCameraConnection, +) ([]string, error) { + client := onvifHTTPClient(connection.SkipTLSVerify) + var lastErr error + + for _, deviceEndpoint := range onvifDeviceServiceCandidates(connection.Host, connection.Port) { + mediaXAddr, err := onvifGetMediaServiceXAddr( + ctx, + client, + deviceEndpoint, + connection, + ) + if err != nil { + lastErr = err + continue + } + + if mediaXAddr == "" { + mediaXAddr = deviceEndpoint + } + mediaXAddr = onvifResolveEndpoint(deviceEndpoint, mediaXAddr) + + resolutions, err := onvifGetVideoEncoderConfigurationOptions( + ctx, + client, + mediaXAddr, + "", + connection, + ) + if err == nil && len(resolutions) > 0 { + return resolutions, nil + } + if err != nil { + lastErr = err + } + + profileTokens, profileErr := onvifGetProfileTokens( + ctx, + client, + mediaXAddr, + connection, + ) + if profileErr != nil { + lastErr = profileErr + continue + } + + merged := []string{} + for _, profileToken := range profileTokens { + profileResolutions, err := onvifGetVideoEncoderConfigurationOptions( + ctx, + client, + mediaXAddr, + profileToken, + connection, + ) + if err != nil { + lastErr = err + continue + } + merged = append(merged, profileResolutions...) + } + + merged = sortMilestoneResolutionValues(merged) + if len(merged) > 0 { + return merged, nil + } + } + + if lastErr != nil { + return nil, lastErr + } + + return nil, fmt.Errorf("ONVIF returned no video encoder resolution options") +} + +func onvifGetMediaServiceXAddr( + ctx context.Context, + client *http.Client, + deviceEndpoint string, + connection onvifCameraConnection, +) (string, error) { + body := `` + + `Media` + + `` + + responseBody, err := onvifSOAPRequest( + ctx, + client, + deviceEndpoint, + "http://www.onvif.org/ver10/device/wsdl/GetCapabilities", + body, + connection, + ) + if err != nil { + return "", err + } + + xaddrs := xmlTextValuesByLocalName(responseBody, "XAddr") + for _, xaddr := range xaddrs { + if strings.Contains(strings.ToLower(xaddr), "media") { + return strings.TrimSpace(xaddr), nil + } + } + if len(xaddrs) > 0 { + return strings.TrimSpace(xaddrs[0]), nil + } + + return "", fmt.Errorf("ONVIF media service XAddr is missing") +} + +func onvifGetProfileTokens( + ctx context.Context, + client *http.Client, + mediaEndpoint string, + connection onvifCameraConnection, +) ([]string, error) { + body := `` + + responseBody, err := onvifSOAPRequest( + ctx, + client, + mediaEndpoint, + "http://www.onvif.org/ver10/media/wsdl/GetProfiles", + body, + connection, + ) + if err != nil { + return nil, err + } + + return xmlProfileTokens(responseBody), nil +} + +func onvifGetVideoEncoderConfigurationOptions( + ctx context.Context, + client *http.Client, + mediaEndpoint string, + profileToken string, + connection onvifCameraConnection, +) ([]string, error) { + body := `` + if strings.TrimSpace(profileToken) != "" { + body += `` + xmlText(profileToken) + `` + } + body += `` + + responseBody, err := onvifSOAPRequest( + ctx, + client, + mediaEndpoint, + "http://www.onvif.org/ver10/media/wsdl/GetVideoEncoderConfigurationOptions", + body, + connection, + ) + if err != nil { + return nil, err + } + + return xmlResolutionValues(responseBody), nil +} + +func onvifSOAPRequest( + ctx context.Context, + client *http.Client, + endpoint string, + action string, + body string, + connection onvifCameraConnection, +) ([]byte, error) { + var lastErr error + + for _, soap12 := range []bool{true, false} { + responseBody, err := onvifSOAPRequestWithVersion( + ctx, + client, + endpoint, + action, + body, + connection, + soap12, + ) + if err == nil { + return responseBody, nil + } + lastErr = err + } + + return nil, lastErr +} + +func onvifSOAPRequestWithVersion( + ctx context.Context, + client *http.Client, + endpoint string, + action string, + body string, + connection onvifCameraConnection, + soap12 bool, +) ([]byte, error) { + envelopeNS := "http://www.w3.org/2003/05/soap-envelope" + if !soap12 { + envelopeNS = "http://schemas.xmlsoap.org/soap/envelope/" + } + + envelope := `` + + `` + + `` + onvifSecurityHeader(connection.Username, connection.Password) + `` + + `` + body + `` + + `` + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + endpoint, + strings.NewReader(envelope), + ) + if err != nil { + return nil, err + } + + if soap12 { + request.Header.Set("Content-Type", `application/soap+xml; charset=utf-8; action="`+action+`"`) + } else { + request.Header.Set("Content-Type", "text/xml; charset=utf-8") + request.Header.Set("SOAPAction", `"`+action+`"`) + } + request.Header.Set("Accept", "application/soap+xml, text/xml, application/xml") + + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + responseBody, readErr := io.ReadAll(response.Body) + if readErr != nil { + return nil, readErr + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf( + "ONVIF request failed: endpoint=%s status=%d body=%s", + endpoint, + response.StatusCode, + truncateMilestoneLogBody(responseBody), + ) + } + + if faultText := xmlSOAPFaultText(responseBody); faultText != "" { + return nil, fmt.Errorf("ONVIF SOAP fault: %s", faultText) + } + + return responseBody, nil +} + +func onvifSecurityHeader(username string, password string) string { + username = strings.TrimSpace(username) + password = strings.TrimSpace(password) + if username == "" && password == "" { + return "" + } + + nonce := make([]byte, 20) + if _, err := rand.Read(nonce); err != nil { + return "" + } + + created := time.Now().UTC().Format("2006-01-02T15:04:05Z") + hash := sha1.New() + hash.Write(nonce) + hash.Write([]byte(created)) + hash.Write([]byte(password)) + digest := base64.StdEncoding.EncodeToString(hash.Sum(nil)) + encodedNonce := base64.StdEncoding.EncodeToString(nonce) + + return `` + + `` + + `` + xmlText(username) + `` + + `` + + xmlText(digest) + + `` + + `` + + xmlText(encodedNonce) + + `` + + `` + xmlText(created) + `` + + `` + + `` +} + +func onvifDeviceServiceCandidates(host string, port string) []string { + host = strings.TrimSpace(host) + port = strings.TrimSpace(port) + + if parsedHost, parsedPort := parseOnvifHostAndPort(host); parsedHost != "" { + host = parsedHost + if port == "" { + port = parsedPort + } + } + + if port == "" { + port = "80" + } + + ports := []string{port} + if port != "80" { + ports = append(ports, "80") + } + + candidates := []string{} + seen := map[string]struct{}{} + + for _, candidatePort := range ports { + schemes := []string{"http"} + if candidatePort == "443" { + schemes = []string{"https", "http"} + } + + for _, scheme := range schemes { + candidate := scheme + "://" + net.JoinHostPort(host, candidatePort) + "/onvif/device_service" + if _, exists := seen[candidate]; exists { + continue + } + seen[candidate] = struct{}{} + candidates = append(candidates, candidate) + } + } + + return candidates +} + +func parseOnvifHostAndPort(value string) (string, string) { + value = strings.TrimSpace(value) + if value == "" { + return "", "" + } + + if strings.Contains(value, "://") { + parsedURL, err := url.Parse(value) + if err == nil && parsedURL.Hostname() != "" { + return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port()) + } + } + + parsedURL, err := url.Parse("//" + value) + if err == nil && parsedURL.Hostname() != "" { + return strings.TrimSpace(parsedURL.Hostname()), strings.TrimSpace(parsedURL.Port()) + } + + return value, "" +} + +func onvifResolveEndpoint(baseEndpoint string, rawEndpoint string) string { + rawEndpoint = strings.TrimSpace(rawEndpoint) + if rawEndpoint == "" { + return baseEndpoint + } + + parsedEndpoint, err := url.Parse(rawEndpoint) + if err == nil && parsedEndpoint.IsAbs() { + return rawEndpoint + } + + baseURL, err := url.Parse(baseEndpoint) + if err != nil { + return rawEndpoint + } + + relativeURL, err := url.Parse(rawEndpoint) + if err != nil { + return rawEndpoint + } + + return baseURL.ResolveReference(relativeURL).String() +} + +func onvifHTTPClient(skipTLSVerify bool) *http.Client { + client := &http.Client{ + Timeout: onvifResolutionFetchTimeout, + } + + if skipTLSVerify { + client.Transport = &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + } + + return client +} + +func xmlText(value string) string { + var buffer bytes.Buffer + _ = xml.EscapeText(&buffer, []byte(value)) + return buffer.String() +} + +func xmlTextValuesByLocalName(body []byte, localName string) []string { + decoder := xml.NewDecoder(bytes.NewReader(body)) + values := []string{} + + for { + token, err := decoder.Token() + if err != nil { + break + } + + startElement, ok := token.(xml.StartElement) + if !ok || startElement.Name.Local != localName { + continue + } + + var text string + if err := decoder.DecodeElement(&text, &startElement); err == nil { + text = strings.TrimSpace(text) + if text != "" { + values = append(values, text) + } + } + } + + return values +} + +func xmlProfileTokens(body []byte) []string { + decoder := xml.NewDecoder(bytes.NewReader(body)) + tokens := []string{} + seen := map[string]struct{}{} + + for { + token, err := decoder.Token() + if err != nil { + break + } + + startElement, ok := token.(xml.StartElement) + if !ok || startElement.Name.Local != "Profiles" { + continue + } + + for _, attr := range startElement.Attr { + if attr.Name.Local != "token" { + continue + } + + value := strings.TrimSpace(attr.Value) + if value == "" { + continue + } + + if _, exists := seen[value]; exists { + continue + } + seen[value] = struct{}{} + tokens = append(tokens, value) + } + } + + return tokens +} + +func xmlResolutionValues(body []byte) []string { + decoder := xml.NewDecoder(bytes.NewReader(body)) + resolutions := []string{} + seen := map[string]struct{}{} + + inResolution := false + width := 0 + height := 0 + + addResolution := func() { + if width <= 0 || height <= 0 { + return + } + + value := fmt.Sprintf("%dx%d", width, height) + if _, exists := seen[value]; exists { + return + } + seen[value] = struct{}{} + resolutions = append(resolutions, value) + } + + for { + token, err := decoder.Token() + if err != nil { + break + } + + switch typedToken := token.(type) { + case xml.StartElement: + switch typedToken.Name.Local { + case "ResolutionsAvailable", "Resolution": + inResolution = true + width = 0 + height = 0 + case "Width": + if !inResolution { + continue + } + var text string + if err := decoder.DecodeElement(&text, &typedToken); err == nil { + width, _ = strconv.Atoi(strings.TrimSpace(text)) + } + case "Height": + if !inResolution { + continue + } + var text string + if err := decoder.DecodeElement(&text, &typedToken); err == nil { + height, _ = strconv.Atoi(strings.TrimSpace(text)) + } + } + case xml.EndElement: + switch typedToken.Name.Local { + case "ResolutionsAvailable", "Resolution": + addResolution() + inResolution = false + width = 0 + height = 0 + } + } + } + + return sortMilestoneResolutionValues(resolutions) +} + +func xmlSOAPFaultText(body []byte) string { + for _, name := range []string{"Text", "Reason", "faultstring"} { + values := xmlTextValuesByLocalName(body, name) + if len(values) == 0 { + continue + } + return values[0] + } + + return "" +} + +func applyOnvifAvailableResolutionsToMilestoneSettings( + settings map[string]any, + resolutions []string, +) { + if len(resolutions) == 0 { + return + } + + rawStreams, ok := settings["stream"].([]any) + if !ok { + return + } + + for _, rawStream := range rawStreams { + streamMap, ok := rawStream.(map[string]any) + if !ok { + continue + } + + streamMap["availableResolutions"] = mergeMilestoneResolutionValues( + streamMap["availableResolutions"], + milestoneStringValue(streamMap["resolution"]), + resolutions, + ) + } +} + +func applyOnvifAvailableResolutionsToMilestoneActiveStreams( + activeStreams []any, + resolutions []string, +) { + if len(resolutions) == 0 { + return + } + + for _, rawGroup := range activeStreams { + group, ok := rawGroup.(map[string]any) + if !ok { + continue + } + + rawSettings, ok := group["stream"].([]any) + if !ok { + continue + } + + for _, rawSetting := range rawSettings { + setting, ok := rawSetting.(map[string]any) + if !ok { + continue + } + + setting["availableResolutions"] = mergeMilestoneResolutionValues( + setting["availableResolutions"], + milestoneStringValue(setting["resolution"]), + resolutions, + ) + } + } +} + +func mergeMilestoneResolutionValues(existing any, current string, extra []string) []string { + values := []string{} + values = append(values, extractMilestoneResolutionValues(existing, 0)...) + values = append(values, current) + values = append(values, extra...) + return sortMilestoneResolutionValues(values) +} + +func sortMilestoneResolutionValues(values []string) []string { + seen := map[string]struct{}{} + resolutions := []string{} + + for _, value := range values { + resolution := normalizeMilestoneResolution(value) + if resolution == "" { + continue + } + + if _, exists := seen[resolution]; exists { + continue + } + + seen[resolution] = struct{}{} + resolutions = append(resolutions, resolution) + } + + sort.SliceStable(resolutions, func(i int, j int) bool { + iWidth, iHeight := splitMilestoneResolution(resolutions[i]) + jWidth, jHeight := splitMilestoneResolution(resolutions[j]) + iPixels := iWidth * iHeight + jPixels := jWidth * jHeight + if iPixels == jPixels { + if iWidth == jWidth { + return iHeight < jHeight + } + return iWidth < jWidth + } + return iPixels < jPixels + }) + + return resolutions +} + +func splitMilestoneResolution(value string) (int, int) { + parts := strings.Split(normalizeMilestoneResolution(value), "x") + if len(parts) != 2 { + return 0, 0 + } + + width, _ := strconv.Atoi(parts[0]) + height, _ := strconv.Atoi(parts[1]) + return width, height +} + +func pgxErrNoRows() error { + return pgx.ErrNoRows +} diff --git a/backend/operations.go b/backend/operations.go index 47c5fa2..9944d1e 100644 --- a/backend/operations.go +++ b/backend/operations.go @@ -86,6 +86,7 @@ type OperationHistoryEntry struct { OperationID string `json:"operationId"` UserID string `json:"userId"` UserDisplayName string `json:"userDisplayName"` + UserAvatar string `json:"userAvatar"` Action string `json:"action"` Changes []OperationHistoryChange `json:"changes"` CreatedAt time.Time `json:"createdAt"` @@ -648,6 +649,7 @@ func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Reque h.operation_id, COALESCE(h.user_id::TEXT, ''), COALESCE(NULLIF(u.display_name, ''), NULLIF(u.username, ''), u.email, 'Unbekannt'), + COALESCE(u.avatar, ''), h.action, h.changes, h.created_at, @@ -677,6 +679,7 @@ func (s *Server) handleListOperationHistory(w http.ResponseWriter, r *http.Reque &entry.OperationID, &entry.UserID, &entry.UserDisplayName, + &entry.UserAvatar, &entry.Action, &changesRaw, &entry.CreatedAt, diff --git a/backend/routes.go b/backend/routes.go index a28311b..e25eaaa 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("GET /device-loan-users", s.requireAuth(s.handleListDeviceLoanUsers)) mux.HandleFunc("GET /devices/{id}/history", s.requireAuth(s.handleListDeviceHistory)) mux.HandleFunc("POST /devices/{id}/history", s.requireAuth(s.handleCreateDeviceJournalEntry)) @@ -55,6 +56,22 @@ func (s *Server) Routes() http.Handler { "PATCH /devices/{id}/milestone-cameras/{childId}", s.requireAuth(s.handleUpdateMilestoneCameraDetails), ) + mux.HandleFunc( + "GET /devices/{id}/milestone-cameras/{childId}/streams", + s.requireAuth(s.handleGetMilestoneCameraApiStreams), + ) + mux.HandleFunc( + "PUT /devices/{id}/milestone-cameras/{childId}/streams/{streamId}", + s.requireAuth(s.handleUpdateMilestoneApiStream), + ) + mux.HandleFunc( + "GET /devices/{id}/milestone-cameras/{childId}/camera-settings", + s.requireAuth(s.handleGetMilestoneCameraSettingsDetail), + ) + mux.HandleFunc( + "PATCH /devices/{id}/milestone-cameras/{childId}/camera-settings", + s.requireAuth(s.handleUpdateMilestoneCameraSettingsDetail), + ) mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState)) mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware)) @@ -144,6 +161,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("GET /admin/group-chats", s.requireAuth(s.requireRight("administration:read", s.handleAdminListGroupChats))) mux.HandleFunc("PUT /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminUpdateGroupChat))) mux.HandleFunc("DELETE /admin/group-chats/{id}", s.requireAuth(s.requireRight("administration:write", s.handleAdminDeleteGroupChat))) + mux.HandleFunc("POST /admin/group-chats/{id}/empty", s.requireAuth(s.requireRight("administration:write", s.handleAdminEmptyGroupChat))) mux.HandleFunc("GET /admin/calendar/settings", s.requireAuth(s.requireRight("administration:read", s.handleGetCalendarSettings))) mux.HandleFunc("PUT /admin/calendar/settings", s.requireAuth(s.requireRight("administration:write", s.handleUpdateCalendarSettings))) diff --git a/backend/runtime_schema.go b/backend/runtime_schema.go new file mode 100644 index 0000000..a164054 --- /dev/null +++ b/backend/runtime_schema.go @@ -0,0 +1,54 @@ +package main + +import ( + "context" + + "github.com/jackc/pgx/v5/pgxpool" +) + +func ensureRuntimeDeviceSchema(ctx context.Context, db *pgxpool.Pool) error { + queries := []string{ + `CREATE EXTENSION IF NOT EXISTS pgcrypto`, + `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS phone_number TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS computer_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS device_category TEXT NOT NULL DEFAULT ''`, + `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`, + ` + CREATE TABLE IF NOT EXISTS device_relations ( + device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + related_device_id UUID NOT NULL REFERENCES devices(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (device_id, related_device_id), + CONSTRAINT device_relations_no_self_reference CHECK (device_id <> related_device_id) + ) + `, + ` + CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + milestone_hardware_id TEXT NOT NULL, + milestone_device_id TEXT NOT NULL, + device_type TEXT NOT NULL, + name TEXT NOT NULL DEFAULT '', + display_name TEXT NOT NULL DEFAULT '', + short_name TEXT NOT NULL DEFAULT '', + enabled BOOLEAN NOT NULL DEFAULT true, + recording_enabled BOOLEAN NOT NULL DEFAULT false, + recording_storage_id TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (milestone_hardware_id, milestone_device_id, device_type) + ) + `, + `ALTER TABLE IF EXISTS milestone_hardware_child_devices ADD COLUMN IF NOT EXISTS recording_enabled BOOLEAN NOT NULL DEFAULT false`, + `ALTER TABLE IF EXISTS milestone_hardware_child_devices ADD COLUMN IF NOT EXISTS recording_storage_id TEXT NOT NULL DEFAULT ''`, + } + + for _, query := range queries { + if _, err := db.Exec(ctx, query); err != nil { + return err + } + } + + return nil +} diff --git a/backend/server_folders_proxy.go b/backend/server_folders_proxy.go index 702c415..0132601 100644 --- a/backend/server_folders_proxy.go +++ b/backend/server_folders_proxy.go @@ -23,6 +23,12 @@ type serverFoldersAgentConfig struct { Token string } +type serverFoldersAgentHTTPResponse struct { + StatusCode int + Body []byte + ContentType string +} + func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) { s.proxyServerFoldersRequest(w, r, http.MethodGet) } @@ -39,6 +45,58 @@ func (s *Server) handleProxyDeleteServerFolder(w http.ResponseWriter, r *http.Re s.proxyServerFoldersRequest(w, r, http.MethodDelete) } +func newServerFoldersAgentHTTPRequest( + ctx context.Context, + method string, + targetURL string, + body []byte, + config serverFoldersAgentConfig, +) (*http.Request, error) { + request, err := http.NewRequestWithContext( + ctx, + method, + targetURL, + bytes.NewReader(body), + ) + if err != nil { + return nil, err + } + + request.Header.Set("Accept", "application/json") + + if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete { + request.Header.Set("Content-Type", "application/json") + } + + if config.Token != "" { + request.Header.Set("X-Server-Folders-Token", config.Token) + } + + return request, nil +} + +func sendServerFoldersAgentHTTPRequest( + client *http.Client, + request *http.Request, +) (serverFoldersAgentHTTPResponse, error) { + response, err := client.Do(request) + if err != nil { + return serverFoldersAgentHTTPResponse{}, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) + if err != nil { + return serverFoldersAgentHTTPResponse{StatusCode: response.StatusCode}, err + } + + return serverFoldersAgentHTTPResponse{ + StatusCode: response.StatusCode, + Body: responseBody, + ContentType: response.Header.Get("Content-Type"), + }, nil +} + func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) { config, err := s.getServerFoldersAgentConfig(r.Context()) if err != nil { @@ -61,52 +119,76 @@ func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Reques } } - request, err := http.NewRequestWithContext( + request, err := newServerFoldersAgentHTTPRequest( r.Context(), method, targetURL, - bytes.NewReader(body), + body, + config, ) if err != nil { writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden") return } - request.Header.Set("Accept", "application/json") - - if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete { - request.Header.Set("Content-Type", "application/json") - } - - if config.Token != "" { - request.Header.Set("X-Server-Folders-Token", config.Token) - } - client := &http.Client{ Timeout: 20 * time.Second, } - response, err := client.Do(request) + agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request) if err != nil { writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar") return } - defer response.Body.Close() - responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) - if err != nil { - writeError(w, http.StatusBadGateway, "Antwort vom Server-Folders-Agent konnte nicht gelesen werden") - return + if agentResponse.StatusCode == http.StatusUnauthorized { + refreshedConfig, err := s.rotateServerFoldersAgentToken(r.Context(), config) + if err != nil { + writeError( + w, + http.StatusBadGateway, + "Server-Folders-Agent hat Unauthorized zurückgegeben. Token-Erneuerung ist fehlgeschlagen. Bitte Agent-Token prüfen oder Milestone-Einstellungen neu speichern.", + ) + return + } + + request, err = newServerFoldersAgentHTTPRequest( + r.Context(), + method, + targetURL, + body, + refreshedConfig, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden") + return + } + + agentResponse, err = sendServerFoldersAgentHTTPRequest(client, request) + if err != nil { + writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nach Token-Erneuerung nicht erreichbar") + return + } + + if agentResponse.StatusCode == http.StatusUnauthorized { + writeError( + w, + http.StatusBadGateway, + "Server-Folders-Agent hat auch nach Token-Erneuerung Unauthorized zurückgegeben. Bitte Agent-Token prüfen.", + ) + return + } + } - contentType := response.Header.Get("Content-Type") + contentType := agentResponse.ContentType if strings.TrimSpace(contentType) == "" { contentType = "application/json" } w.Header().Set("Content-Type", contentType) - w.WriteHeader(response.StatusCode) - _, _ = w.Write(responseBody) + w.WriteHeader(agentResponse.StatusCode) + _, _ = w.Write(agentResponse.Body) } func (s *Server) requestServerFoldersAgent( @@ -133,51 +215,60 @@ func (s *Server) requestServerFoldersAgent( } } - request, err := http.NewRequestWithContext( + request, err := newServerFoldersAgentHTTPRequest( ctx, method, targetURL, - bytes.NewReader(body), + body, + config, ) if err != nil { return nil, 0, err } - request.Header.Set("Accept", "application/json") - if payload != nil { - request.Header.Set("Content-Type", "application/json") - } - - if config.Token != "" { - request.Header.Set("X-Server-Folders-Token", config.Token) - } - client := &http.Client{ Timeout: 30 * time.Second, } - response, err := client.Do(request) + agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request) if err != nil { return nil, 0, err } - defer response.Body.Close() - responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) - if err != nil { - return nil, response.StatusCode, err + if agentResponse.StatusCode == http.StatusUnauthorized { + refreshedConfig, err := s.rotateServerFoldersAgentToken(ctx, config) + if err != nil { + return agentResponse.Body, agentResponse.StatusCode, err + } + + request, err = newServerFoldersAgentHTTPRequest( + ctx, + method, + targetURL, + body, + refreshedConfig, + ) + if err != nil { + return nil, 0, err + } + + agentResponse, err = sendServerFoldersAgentHTTPRequest(client, request) + if err != nil { + return nil, 0, err + } } - if response.StatusCode < 200 || response.StatusCode >= 300 { - return responseBody, response.StatusCode, fmt.Errorf( + if agentResponse.StatusCode < 200 || agentResponse.StatusCode >= 300 { + return agentResponse.Body, agentResponse.StatusCode, fmt.Errorf( "server-folders-agent request failed: method=%s url=%s status=%d body=%s", method, targetURL, - response.StatusCode, - string(responseBody), + agentResponse.StatusCode, + string(agentResponse.Body), ) } - return responseBody, response.StatusCode, nil + return agentResponse.Body, agentResponse.StatusCode, nil } type serverFolderAgentFolderResponse struct { @@ -413,6 +504,38 @@ func (s *Server) pushServerFoldersAgentToken( return nil } +func (s *Server) rotateServerFoldersAgentToken( + ctx context.Context, + config serverFoldersAgentConfig, +) (serverFoldersAgentConfig, error) { + newToken, err := generateServerFoldersAgentToken() + if err != nil { + return config, err + } + + if err := s.pushServerFoldersAgentToken(ctx, config, newToken); err != nil { + return config, err + } + + if _, err := s.db.Exec( + ctx, + ` + UPDATE milestone_settings + SET + server_folders_agent_token_encrypted = pgp_sym_encrypt($1, $2), + updated_at = now() + WHERE id = 1 + `, + newToken, + s.milestoneEncryptionKey(), + ); err != nil { + return config, err + } + + config.Token = newToken + return config, nil +} + func forwardedServerFoldersQuery(values url.Values) string { nextValues := url.Values{} diff --git a/backend/setup/main.go b/backend/setup/main.go index 0eefd6e..78a7f07 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -417,8 +417,13 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { serial_number TEXT NOT NULL DEFAULT '', mac_address TEXT NOT NULL DEFAULT '', ip_address TEXT NOT NULL DEFAULT '', + phone_number TEXT NOT NULL DEFAULT '', + computer_name TEXT NOT NULL DEFAULT '', + device_category TEXT NOT NULL DEFAULT '', location TEXT NOT NULL DEFAULT '', loan_status TEXT NOT NULL DEFAULT 'Verfügbar', + loaned_to_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + loaned_until DATE, is_bao_device BOOLEAN NOT NULL DEFAULT false, comment TEXT NOT NULL DEFAULT '', @@ -434,6 +439,17 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS phone_number TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS computer_name TEXT NOT NULL DEFAULT ''`, + `ALTER TABLE IF EXISTS devices ADD COLUMN IF NOT EXISTS device_category TEXT NOT NULL DEFAULT ''`, + `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`, + ` + UPDATE devices + SET device_category = 'Kameras' + WHERE COALESCE(milestone_hardware_id, '') <> '' + AND COALESCE(device_category, '') = '' + `, ` CREATE TABLE IF NOT EXISTS milestone_hardware_child_devices ( @@ -741,6 +757,18 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { ) `, + ` + CREATE TABLE IF NOT EXISTS onvif_resolution_cache ( + milestone_hardware_id TEXT PRIMARY KEY, + host TEXT NOT NULL DEFAULT '', + port TEXT NOT NULL DEFAULT '', + resolutions JSONB NOT NULL DEFAULT '[]'::JSONB, + check_error TEXT NOT NULL DEFAULT '', + checked_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + `, + // ── Chat ──────────────────────────────────────────────────────────── // Eine Konversation ist entweder ein Team-Gruppenchat, Einzelchat, // freier Gruppenchat oder ein schreibgeschützter Integrations-Channel. diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index e3cb98f..99261fb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -// frontend/src/App.tsx +// frontend\src\App.tsx import { useCallback, useEffect, useState } from 'react' import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router' @@ -18,10 +18,12 @@ import NotificationCenter from './components/NotificationCenter' import type { Notification, User } from './components/types' import AdministrationPage from './pages/administration/AdministrationPage' import Feedback from './components/Feedback' -import MilestoneStoragePage from './pages/operations/milestone-storage/MilestoneStoragePage' -import MilestoneDevicesPage from './pages/operations/milestone-devices/MilestoneDevicesPage' +import MilestonePage from './pages/milestone/MilestonePage' +import MilestoneStoragePage from './pages/milestone/speicher/MilestoneStoragePage' +import MilestoneDevicesPage from './pages/milestone/geraete/MilestoneDevicesPage' import { hasRight } from './components/permissions' import ChatPage from './pages/chat/ChatPage' +import { ChatSocketProvider } from './components/ChatSocketProvider' import { recordNavigation } from './utils/activityLog' import { syncExistingPushSubscription } from './utils/pushNotifications' import { notifyNewMessage } from './utils/titleNotifier' @@ -624,139 +626,161 @@ function App() { } return ( - - } - > - - } /> + + + } + > + + } /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + } + /> - - - - } - /> + } + /> - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - - - - } - /> + + + + } + /> - } /> - } /> - } - /> - } - /> - } - /> + } + /> - } /> - - + + + + } + /> + + + + + } + /> + + } /> + } /> + } + /> + } + /> + } + /> + + } /> + + + ) } diff --git a/frontend/src/components/ChatSocketProvider.tsx b/frontend/src/components/ChatSocketProvider.tsx new file mode 100644 index 0000000..78f0af3 --- /dev/null +++ b/frontend/src/components/ChatSocketProvider.tsx @@ -0,0 +1,151 @@ +// frontend/src/components/ChatSocketProvider.tsx + +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from 'react' + +const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' + +function getChatWebSocketUrl() { + const url = new URL( + `${API_URL.replace(/\/$/, '')}/chat/ws`, + window.location.origin, + ) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.toString() +} + +type ChatSocketListener = (payload: unknown) => void + +type ChatSocketContextValue = { + connected: boolean + // Sendet ein Kommando; gibt zurück, ob die Verbindung offen war. + send: (command: unknown) => boolean + // Registriert einen Listener für eingehende Ereignisse; liefert Unsubscribe. + subscribe: (listener: ChatSocketListener) => () => void +} + +const ChatSocketContext = createContext(null) + +// ChatSocketProvider hält genau EINE WebSocket-Verbindung für die gesamte App +// offen, solange er eingehängt ist (d. h. ab Login). Verbraucher wie ChatPage +// abonnieren Ereignisse über subscribe() und senden über send(), statt jeweils +// eine eigene Verbindung aufzubauen. +export function ChatSocketProvider({ children }: { children: ReactNode }) { + const socketRef = useRef(null) + const listenersRef = useRef(new Set()) + const [connected, setConnected] = useState(false) + + useEffect(() => { + let socket: WebSocket | null = null + let reconnectTimer: number | undefined + let reconnectAttempt = 0 + let stopped = false + + function connect() { + socket = new WebSocket(getChatWebSocketUrl()) + socketRef.current = socket + + socket.onopen = () => { + reconnectAttempt = 0 + setConnected(true) + } + + socket.onmessage = (event) => { + let payload: unknown + try { + payload = JSON.parse(event.data) + } catch { + return + } + + if ( + payload && + typeof payload === 'object' && + (payload as { type?: unknown }).type === 'connected' + ) { + setConnected(true) + } + + listenersRef.current.forEach((listener) => listener(payload)) + } + + socket.onclose = () => { + if (socketRef.current === socket) { + socketRef.current = null + } + setConnected(false) + + if (stopped) { + return + } + + const delay = Math.min(1000 * 2 ** reconnectAttempt, 10_000) + reconnectAttempt += 1 + reconnectTimer = window.setTimeout(connect, delay) + } + } + + connect() + + return () => { + stopped = true + setConnected(false) + if (reconnectTimer !== undefined) { + window.clearTimeout(reconnectTimer) + } + socketRef.current = null + socket?.close(1000, 'Chat geschlossen') + } + }, []) + + const send = useCallback((command: unknown) => { + const socket = socketRef.current + if (!socket || socket.readyState !== WebSocket.OPEN) { + return false + } + + try { + socket.send(JSON.stringify(command)) + return true + } catch { + return false + } + }, []) + + const subscribe = useCallback((listener: ChatSocketListener) => { + const listeners = listenersRef.current + listeners.add(listener) + return () => { + listeners.delete(listener) + } + }, []) + + const value = useMemo( + () => ({ connected, send, subscribe }), + [connected, send, subscribe], + ) + + return ( + + {children} + + ) +} + +export function useChatSocket() { + const context = useContext(ChatSocketContext) + if (!context) { + throw new Error( + 'useChatSocket muss innerhalb von ChatSocketProvider verwendet werden', + ) + } + return context +} diff --git a/frontend/src/components/Combobox.tsx b/frontend/src/components/Combobox.tsx index a7c940b..549698f 100644 --- a/frontend/src/components/Combobox.tsx +++ b/frontend/src/components/Combobox.tsx @@ -2,7 +2,7 @@ 'use client' -import { useState } from 'react' +import { useState, type ComponentType, type SVGProps } from 'react' import { Combobox as HeadlessCombobox, ComboboxButton, @@ -27,6 +27,7 @@ export type ComboboxItem = { type ComboboxProps = { label?: string + labelIcon?: ComponentType> items: ComboboxItem[] value: ComboboxItem | null onChange: (item: ComboboxItem | null) => void @@ -51,6 +52,7 @@ function getSecondaryText(item: ComboboxItem) { export default function Combobox({ label, + labelIcon: LabelIcon, items, value, onChange, @@ -154,8 +156,18 @@ export default function Combobox({ className={className} > {label && ( -