diff --git a/backend/admin_milestone.go b/backend/admin_milestone.go index 971edc9..7366693 100644 --- a/backend/admin_milestone.go +++ b/backend/admin_milestone.go @@ -35,6 +35,10 @@ type milestoneSettingsResponse struct { ServerVersion string `json:"serverVersion"` ServerUpdatedAt *time.Time `json:"serverUpdatedAt,omitempty"` + ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` + ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` + ServerFoldersAgentTokenConfigured bool `json:"serverFoldersAgentTokenConfigured"` + UpdatedAt *time.Time `json:"updatedAt,omitempty"` } @@ -43,6 +47,10 @@ type updateMilestoneSettingsRequest struct { Username string `json:"username"` Password string `json:"password"` SkipTLSVerify bool `json:"skipTlsVerify"` + + ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` + ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` + ServerFoldersAgentToken string `json:"serverFoldersAgentToken"` } type milestoneCredentials struct { @@ -52,126 +60,25 @@ type milestoneCredentials struct { SkipTLSVerify bool } -type milestoneSyncResult struct { - TokenReceived bool `json:"tokenReceived"` - TokenType string `json:"tokenType"` - TokenExpiresAt time.Time `json:"tokenExpiresAt"` - RecordingServerID string `json:"recordingServerId"` - RecordingServerName string `json:"recordingServerName"` - RecordingServerVersion string `json:"recordingServerVersion"` - HardwareCount int `json:"hardwareCount"` -} - -type milestoneSyncEvent struct { - Type string `json:"type"` - Message string `json:"message,omitempty"` - TokenType string `json:"tokenType,omitempty"` - TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` - RecordingServerID string `json:"recordingServerId,omitempty"` - RecordingServerName string `json:"recordingServerName,omitempty"` - RecordingServerVersion string `json:"recordingServerVersion,omitempty"` - HardwareCount int `json:"hardwareCount,omitempty"` - Settings *milestoneSettingsResponse `json:"settings,omitempty"` -} - type milestoneTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } -type milestoneRecordingServersResponse struct { - Array []milestoneRecordingServer `json:"array"` -} - -type milestoneRecordingServer struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - Version string `json:"version"` - ProductVersion string `json:"productVersion"` - RecordingServerVersion string `json:"recordingServerVersion"` -} - -type milestoneHardwareResponse struct { - Array []milestoneHardware `json:"array"` -} - -type milestoneHardware struct { - ID string `json:"id"` - Name string `json:"name"` - DisplayName string `json:"displayName"` - DeviceName string `json:"deviceName"` - Address string `json:"address"` - Enabled *bool `json:"enabled"` - Disabled *bool `json:"disabled"` - Relations milestoneRelations `json:"relations"` -} - -type milestoneRelations struct { - Parent milestoneRelationRef `json:"parent"` - Self milestoneRelationRef `json:"self"` -} - -type milestoneRelationRef struct { - Type string `json:"type"` - ID string `json:"id"` -} - -type milestoneHardwareDriverSettingsResponse struct { - Array []milestoneHardwareDriverSettingsItem `json:"array"` -} - -type milestoneHardwareDriverSettingsItem struct { - DisplayName string `json:"displayName"` - HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"` - Relations milestoneRelations `json:"relations"` -} - -type milestoneHardwareDriverSettings struct { - DetectedModelName string `json:"detectedModelName"` - ProductID string `json:"productID"` - MacAddress string `json:"macAddress"` - IPAddress string `json:"ipAddress"` - Address string `json:"address"` - HostName string `json:"hostName"` - DeviceModelName string `json:"deviceModelName"` - DeviceManufacturer string `json:"deviceManufacturer"` - FirmwareVersion string `json:"firmwareVersion"` - SerialNumber string `json:"serialNumber"` -} - -type milestoneHardwareDevice struct { - HardwareID string - RecordingServerID string - DisplayName string - DeviceName string - Manufacturer string - MacAddress string - IPAddress string - Port string - DeviceModelName string - SerialNumber string - FirmwareVersion string - Enabled bool - Cameras []milestoneNamedDevice - Microphones []milestoneNamedDevice - Inputs []milestoneNamedDevice - Outputs []milestoneNamedDevice - Speakers []milestoneNamedDevice - Metadata []milestoneNamedDevice -} - func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) { settings, err := s.getMilestoneSettings(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeJSON(w, http.StatusOK, map[string]any{ "settings": milestoneSettingsResponse{ - Host: "", - Username: "", - PasswordConfigured: false, - SkipTLSVerify: false, - TokenConfigured: false, + Host: "", + Username: "", + PasswordConfigured: false, + SkipTLSVerify: false, + TokenConfigured: false, + ServerFoldersAgentScheme: "http", + ServerFoldersAgentPort: "8099", + ServerFoldersAgentTokenConfigured: false, }, }) return @@ -199,6 +106,11 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re username := strings.TrimSpace(input.Username) password := strings.TrimSpace(input.Password) skipTLSVerify := input.SkipTLSVerify + serverFoldersAgentScheme, serverFoldersAgentPort := normalizeServerFoldersAgentSettings( + input.ServerFoldersAgentScheme, + input.ServerFoldersAgentPort, + ) + serverFoldersAgentToken := strings.TrimSpace(input.ServerFoldersAgentToken) if host == "" { writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") @@ -240,7 +152,10 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re access_token_encrypted, token_type, token_expires_at, - token_updated_at + token_updated_at, + server_folders_agent_scheme, + server_folders_agent_port, + server_folders_agent_token_encrypted ) VALUES ( 1, @@ -251,7 +166,13 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re NULL, '', NULL, - NULL + NULL, + $6, + $7, + CASE + WHEN $8 = '' THEN NULL + ELSE pgp_sym_encrypt($8, $4) + END ) ON CONFLICT (id) DO UPDATE SET @@ -263,6 +184,12 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_type = '', token_expires_at = NULL, token_updated_at = NULL, + server_folders_agent_scheme = EXCLUDED.server_folders_agent_scheme, + server_folders_agent_port = EXCLUDED.server_folders_agent_port, + server_folders_agent_token_encrypted = CASE + WHEN $8 = '' THEN milestone_settings.server_folders_agent_token_encrypted + ELSE EXCLUDED.server_folders_agent_token_encrypted + END, updated_at = now() `, host, @@ -270,6 +197,9 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re password, encryptionKey, skipTLSVerify, + serverFoldersAgentScheme, + serverFoldersAgentPort, + serverFoldersAgentToken, ) } else { _, err = s.db.Exec( @@ -284,12 +214,22 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re token_type = '', token_expires_at = NULL, token_updated_at = NULL, + server_folders_agent_scheme = $4, + server_folders_agent_port = $5, + server_folders_agent_token_encrypted = CASE + WHEN $6 = '' THEN server_folders_agent_token_encrypted + ELSE pgp_sym_encrypt($6, $7) + END, updated_at = now() WHERE id = 1 `, host, username, skipTLSVerify, + serverFoldersAgentScheme, + serverFoldersAgentPort, + serverFoldersAgentToken, + encryptionKey, ) } @@ -321,7 +261,7 @@ func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Re if err != nil { log.Printf("Milestone sync after settings save failed: %v", err) - syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Hardware konnten nicht vollständig abgerufen werden." + syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Milestone-Daten konnten nicht vollständig abgerufen werden." } } else { syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden." @@ -367,7 +307,7 @@ func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Reques if err != nil { log.Printf("Milestone token/server sync failed: %v", err) - writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden") + writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Milestone-Daten konnten nicht abgerufen werden") return } @@ -416,7 +356,7 @@ func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http. writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "error", - Message: "Milestone-Token, Recording-Server oder Hardware konnten nicht abgerufen werden", + Message: "Milestone-Token, Recording-Server oder Milestone-Daten konnten nicht abgerufen werden", }) return } @@ -436,26 +376,6 @@ func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http. }) } -func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) { - if err := json.NewEncoder(w).Encode(event); err != nil { - log.Printf("Milestone sync stream event could not be written: %v", err) - return - } - - if flusher, ok := w.(http.Flusher); ok { - flusher.Flush() - } -} - -func emitMilestoneSyncEvent( - progress func(milestoneSyncEvent), - event milestoneSyncEvent, -) { - if progress != nil { - progress(event) - } -} - func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) { var settings milestoneSettingsResponse var updatedAt time.Time @@ -479,6 +399,9 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes COALESCE(server_name, ''), COALESCE(server_version, ''), server_updated_at, + COALESCE(server_folders_agent_scheme, 'http'), + COALESCE(server_folders_agent_port, '8099'), + server_folders_agent_token_encrypted IS NOT NULL, updated_at FROM milestone_settings WHERE id = 1 @@ -497,6 +420,9 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes &settings.ServerName, &settings.ServerVersion, &serverUpdatedAt, + &settings.ServerFoldersAgentScheme, + &settings.ServerFoldersAgentPort, + &settings.ServerFoldersAgentTokenConfigured, &updatedAt, ) @@ -512,818 +438,6 @@ func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsRes return settings, nil } -func (s *Server) refreshMilestoneTokenAndServerID( - ctx context.Context, - credentials milestoneCredentials, -) (milestoneSyncResult, error) { - return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil) -} - -func (s *Server) refreshMilestoneTokenAndServerIDWithProgress( - ctx context.Context, - credentials milestoneCredentials, - progress func(milestoneSyncEvent), -) (milestoneSyncResult, error) { - var syncResult milestoneSyncResult - - tokenResponse, err := s.requestMilestoneToken(ctx, credentials) - if err != nil { - return syncResult, err - } - - accessToken := strings.TrimSpace(tokenResponse.AccessToken) - if accessToken == "" { - return syncResult, errors.New("milestone returned empty access token") - } - - tokenType := strings.TrimSpace(tokenResponse.TokenType) - if tokenType == "" { - tokenType = "Bearer" - } - - expiresIn := tokenResponse.ExpiresIn - if expiresIn <= 0 { - expiresIn = 3600 - } - - tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) - - syncResult.TokenReceived = true - syncResult.TokenType = tokenType - syncResult.TokenExpiresAt = tokenExpiresAt - - emitMilestoneSyncEvent(progress, milestoneSyncEvent{ - Type: "token", - TokenType: tokenType, - TokenExpiresAt: &tokenExpiresAt, - }) - - serverID, serverName, serverVersion, err := s.requestMilestoneServerID( - ctx, - credentials.Host, - tokenType, - accessToken, - credentials.SkipTLSVerify, - ) - if err != nil { - return syncResult, err - } - - syncResult.RecordingServerID = serverID - syncResult.RecordingServerName = serverName - syncResult.RecordingServerVersion = serverVersion - - emitMilestoneSyncEvent(progress, milestoneSyncEvent{ - Type: "recordingServer", - RecordingServerID: serverID, - RecordingServerName: serverName, - RecordingServerVersion: serverVersion, - }) - - hardwareDevices, err := s.requestMilestoneHardwareDevices( - ctx, - credentials.Host, - serverID, - accessToken, - credentials.SkipTLSVerify, - ) - if err != nil { - return syncResult, err - } - - tx, err := s.db.Begin(ctx) - if err != nil { - return syncResult, err - } - defer tx.Rollback(ctx) - - encryptionKey := s.milestoneEncryptionKey() - - _, err = tx.Exec( - ctx, - ` - UPDATE milestone_settings - SET - access_token_encrypted = pgp_sym_encrypt($1, $2), - token_type = $3, - token_expires_at = $4, - token_updated_at = now(), - server_id = $5, - server_name = $6, - server_version = $7, - server_updated_at = now(), - updated_at = now() - WHERE id = 1 - `, - accessToken, - encryptionKey, - tokenType, - tokenExpiresAt, - serverID, - serverName, - serverVersion, - ) - if err != nil { - return syncResult, err - } - - syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices) - if err != nil { - return syncResult, err - } - - syncResult.HardwareCount = syncedHardwareCount - - if err := tx.Commit(ctx); err != nil { - return syncResult, err - } - - emitMilestoneSyncEvent(progress, milestoneSyncEvent{ - Type: "hardware", - HardwareCount: syncResult.HardwareCount, - }) - - log.Printf( - "Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d", - serverID, - syncedHardwareCount, - ) - - return syncResult, nil -} - -func (s *Server) requestMilestoneServerID( - ctx context.Context, - host string, - tokenType string, - accessToken string, - skipTLSVerify bool, -) (string, string, string, error) { - requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers" - - log.Printf( - "Requesting Milestone recording server id: url=%s skipTLSVerify=%t", - requestURL, - skipTLSVerify, - ) - - request, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - requestURL, - nil, - ) - if err != nil { - return "", "", "", err - } - - request.Header.Set("Authorization", "Bearer "+accessToken) - request.Header.Set("Accept", "application/json") - - client := milestoneHTTPClient(skipTLSVerify) - - response, err := client.Do(request) - if err != nil { - return "", "", "", err - } - defer response.Body.Close() - - body, err := io.ReadAll(response.Body) - if err != nil { - return "", "", "", err - } - - if response.StatusCode < 200 || response.StatusCode >= 300 { - return "", "", "", fmt.Errorf( - "milestone recording servers request failed: url=%s status=%d body=%s", - requestURL, - response.StatusCode, - truncateMilestoneLogBody(body), - ) - } - - var recordingServersResponse milestoneRecordingServersResponse - if err := json.Unmarshal(body, &recordingServersResponse); err != nil { - return "", "", "", fmt.Errorf( - "milestone recording servers response could not be parsed: body=%s error=%w", - truncateMilestoneLogBody(body), - err, - ) - } - - if len(recordingServersResponse.Array) == 0 { - return "", "", "", fmt.Errorf( - "milestone returned no recording servers: body=%s", - truncateMilestoneLogBody(body), - ) - } - - recordingServer := recordingServersResponse.Array[0] - - recordingServerID := strings.TrimSpace(recordingServer.ID) - recordingServerName := strings.TrimSpace(recordingServer.DisplayName) - - if recordingServerName == "" { - recordingServerName = strings.TrimSpace(recordingServer.Name) - } - - recordingServerVersion := strings.TrimSpace(recordingServer.Version) - - if recordingServerVersion == "" { - recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion) - } - - if recordingServerVersion == "" { - recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion) - } - - if recordingServerID == "" { - return "", "", "", fmt.Errorf( - "milestone returned recording server without id: body=%s", - truncateMilestoneLogBody(body), - ) - } - - log.Printf( - "Milestone recording server received successfully: id=%s displayName=%q version=%q", - recordingServerID, - recordingServerName, - recordingServerVersion, - ) - - return recordingServerID, recordingServerName, recordingServerVersion, nil -} - -func (s *Server) requestMilestoneHardwareDevices( - ctx context.Context, - host string, - recordingServerID string, - accessToken string, - skipTLSVerify bool, -) ([]milestoneHardwareDevice, error) { - hardwareItems, err := s.requestMilestoneHardware( - ctx, - host, - recordingServerID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - result := make([]milestoneHardwareDevice, 0, len(hardwareItems)) - - for _, hardware := range hardwareItems { - hardwareID := strings.TrimSpace(hardware.ID) - if hardwareID == "" { - hardwareID = strings.TrimSpace(hardware.Relations.Self.ID) - } - - if hardwareID == "" { - log.Printf("Milestone hardware skipped: missing hardware id") - continue - } - - settings, err := s.requestMilestoneHardwareDriverSettings( - ctx, - host, - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - hardwareDetails, err := s.requestMilestoneHardwareDetails( - ctx, - host, - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - displayName := strings.TrimSpace(hardware.DisplayName) - if displayName == "" { - displayName = strings.TrimSpace(hardware.Name) - } - - model := strings.TrimSpace(settings.DeviceModelName) - if model == "" { - model = strings.TrimSpace(settings.DetectedModelName) - } - - manufacturer := normalizeMilestoneManufacturer( - settings.DeviceManufacturer, - model, - ) - - if manufacturer == "" { - manufacturer = normalizeMilestoneManufacturer( - settings.ProductID, - model, - ) - } - - if manufacturer == "" { - manufacturer = extractMilestoneManufacturerFromName(model) - } - - deviceName := strings.TrimSpace(hardware.DeviceName) - if deviceName == "" { - deviceName = displayName - } - - enabled := true - - if hardware.Enabled != nil { - enabled = *hardware.Enabled - } - - if hardware.Disabled != nil { - enabled = !*hardware.Disabled - } - - hardwareAddress := strings.TrimSpace(milestoneStringValue(hardwareDetails["address"])) - if hardwareAddress == "" { - hardwareAddress = hardware.Address - } - - ipAddress, port := extractMilestoneHardwareAddressParts(hardwareAddress) - - embeddedCameras := milestoneNamedDevicesFromHardwareCollection( - hardwareDetails, - "cameras", - ) - - embeddedMicrophones := milestoneNamedDevicesFromHardwareCollection( - hardwareDetails, - "microphones", - ) - - embeddedInputs := milestoneNamedDevicesFromHardwareCollection( - hardwareDetails, - "inputs", - ) - - embeddedOutputs := milestoneNamedDevicesFromHardwareCollection( - hardwareDetails, - "outputs", - ) - - embeddedSpeakers := milestoneNamedDevicesFromHardwareCollection( - hardwareDetails, - "speakers", - ) - - embeddedMetadata := milestoneNamedDevicesFromHardwareCollection( - hardwareDetails, - "metadata", - ) - - camerasWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( - ctx, - host, - "cameras", - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - microphonesWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( - ctx, - host, - "microphones", - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - inputsWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( - ctx, - host, - "inputs", - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - outputsWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( - ctx, - host, - "outputs", - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - speakersWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( - ctx, - host, - "speakers", - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - metadataWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( - ctx, - host, - "metadata", - hardwareID, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - cameras := mergeMilestoneNamedDevices( - embeddedCameras, - camerasWithDisabled, - ) - - microphones := mergeMilestoneNamedDevices( - embeddedMicrophones, - microphonesWithDisabled, - ) - - inputs := mergeMilestoneNamedDevices( - embeddedInputs, - inputsWithDisabled, - ) - - outputs := mergeMilestoneNamedDevices( - embeddedOutputs, - outputsWithDisabled, - ) - - speakers := mergeMilestoneNamedDevices( - embeddedSpeakers, - speakersWithDisabled, - ) - - metadata := mergeMilestoneNamedDevices( - embeddedMetadata, - metadataWithDisabled, - ) - - result = append(result, milestoneHardwareDevice{ - HardwareID: hardwareID, - RecordingServerID: recordingServerID, - DisplayName: displayName, - DeviceName: deviceName, - Manufacturer: manufacturer, - MacAddress: strings.TrimSpace(settings.MacAddress), - IPAddress: ipAddress, - Port: port, - DeviceModelName: model, - SerialNumber: strings.TrimSpace(settings.SerialNumber), - FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), - Enabled: enabled, - Cameras: cameras, - Microphones: microphones, - Inputs: inputs, - Outputs: outputs, - Speakers: speakers, - Metadata: metadata, - }) - } - - cameraCount := 0 - microphoneCount := 0 - inputCount := 0 - outputCount := 0 - speakerCount := 0 - metadataCount := 0 - - for _, hardwareDevice := range result { - cameraCount += len(hardwareDevice.Cameras) - microphoneCount += len(hardwareDevice.Microphones) - inputCount += len(hardwareDevice.Inputs) - outputCount += len(hardwareDevice.Outputs) - speakerCount += len(hardwareDevice.Speakers) - metadataCount += len(hardwareDevice.Metadata) - } - - log.Printf( - "Milestone hardware loaded successfully: recordingServerId=%s count=%d cameras=%d microphones=%d inputs=%d outputs=%d speakers=%d metadata=%d", - recordingServerID, - len(result), - cameraCount, - microphoneCount, - inputCount, - outputCount, - speakerCount, - metadataCount, - ) - - return result, nil -} - -func (s *Server) requestMilestoneNamedDevicesByHardware( - ctx context.Context, - host string, - collection string, - hardwareID string, - accessToken string, - skipTLSVerify bool, -) ([]milestoneNamedDevice, error) { - switch collection { - case "cameras", "microphones": - default: - return nil, fmt.Errorf("unsupported milestone device collection: %s", collection) - } - - requestURL := strings.TrimRight(host, "/") + - "/API/rest/v1/" + - collection + - "?disabled" - - log.Printf( - "Requesting Milestone %s with disabled: url=%s hardwareId=%s skipTLSVerify=%t", - collection, - requestURL, - hardwareID, - skipTLSVerify, - ) - - body, err := requestMilestoneJSON( - ctx, - requestURL, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - return milestoneNamedDevicesFromListResponse(body, collection, hardwareID) -} - -func extractMilestoneHardwareIPAddress(value string) string { - host, _ := extractMilestoneHardwareAddressParts(value) - return host -} - -func defaultMilestonePortForScheme(scheme string) string { - switch strings.ToLower(strings.TrimSpace(scheme)) { - case "https": - return "443" - case "http": - return "80" - default: - return "80" - } -} - -func extractMilestoneHardwareAddressParts(value string) (string, string) { - value = strings.TrimSpace(value) - if value == "" { - return "", "" - } - - parsedURL, err := url.Parse(value) - if err == nil && parsedURL.Hostname() != "" { - scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme)) - if scheme == "" { - scheme = "http" - } - - port := strings.TrimSpace(parsedURL.Port()) - if port == "" { - port = defaultMilestonePortForScheme(scheme) - } - - return strings.TrimSpace(parsedURL.Hostname()), port - } - - scheme := "http" - lowerValue := strings.ToLower(value) - - if strings.HasPrefix(lowerValue, "https://") { - scheme = "https" - } - - value = strings.TrimPrefix(value, "http://") - value = strings.TrimPrefix(value, "https://") - value = strings.TrimRight(value, "/") - - if host, port, found := strings.Cut(value, ":"); found { - port = strings.TrimSpace(port) - - if isOnlyDigits(port) { - return strings.TrimSpace(host), port - } - } - - return strings.TrimSpace(value), defaultMilestonePortForScheme(scheme) -} - -func extractMilestoneHardwarePort(value string) string { - _, port := extractMilestoneHardwareAddressParts(value) - return port -} - -func (s *Server) requestMilestoneHardware( - ctx context.Context, - host string, - recordingServerID string, - accessToken string, - skipTLSVerify bool, -) ([]milestoneHardware, error) { - requestURL := strings.TrimRight(host, "/") + - "/API/rest/v1/recordingServers/" + - url.PathEscape(recordingServerID) + - "/hardware?disabled" - - log.Printf( - "Requesting Milestone hardware: url=%s skipTLSVerify=%t", - requestURL, - skipTLSVerify, - ) - - body, err := requestMilestoneJSON( - ctx, - requestURL, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - var hardwareResponse milestoneHardwareResponse - if err := json.Unmarshal(body, &hardwareResponse); err != nil { - return nil, fmt.Errorf( - "milestone hardware response could not be parsed: body=%s error=%w", - truncateMilestoneLogBody(body), - err, - ) - } - - return hardwareResponse.Array, nil -} - -func (s *Server) requestMilestoneHardwareDetails( - ctx context.Context, - host string, - hardwareID string, - accessToken string, - skipTLSVerify bool, -) (map[string]any, error) { - requestURL := strings.TrimRight(host, "/") + - "/API/rest/v1/hardware/" + - url.PathEscape(hardwareID) - - log.Printf( - "Requesting Milestone hardware details: url=%s skipTLSVerify=%t", - requestURL, - skipTLSVerify, - ) - - body, err := requestMilestoneJSON( - ctx, - requestURL, - accessToken, - skipTLSVerify, - ) - if err != nil { - return nil, err - } - - var envelope milestoneHardwareUpdateEnvelope - if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { - return envelope.Data, nil - } - - var direct map[string]any - if err := json.Unmarshal(body, &direct); err != nil { - return nil, fmt.Errorf( - "milestone hardware details response could not be parsed: body=%s error=%w", - truncateMilestoneLogBody(body), - err, - ) - } - - if data, ok := direct["data"].(map[string]any); ok { - return data, nil - } - - return direct, nil -} - -func (s *Server) requestMilestoneHardwareDriverSettings( - ctx context.Context, - host string, - hardwareID string, - accessToken string, - skipTLSVerify bool, -) (milestoneHardwareDriverSettings, error) { - requestURL := strings.TrimRight(host, "/") + - "/API/rest/v1/hardware/" + - url.PathEscape(hardwareID) + - "/hardwareDriverSettings" - - log.Printf( - "Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t", - requestURL, - skipTLSVerify, - ) - - body, err := requestMilestoneJSON( - ctx, - requestURL, - accessToken, - skipTLSVerify, - ) - if err != nil { - return milestoneHardwareDriverSettings{}, err - } - - var settingsResponse milestoneHardwareDriverSettingsResponse - if err := json.Unmarshal(body, &settingsResponse); err != nil { - return milestoneHardwareDriverSettings{}, fmt.Errorf( - "milestone hardware driver settings response could not be parsed: body=%s error=%w", - truncateMilestoneLogBody(body), - err, - ) - } - - if len(settingsResponse.Array) == 0 { - return milestoneHardwareDriverSettings{}, fmt.Errorf( - "milestone returned no hardware driver settings: hardwareId=%s body=%s", - hardwareID, - truncateMilestoneLogBody(body), - ) - } - - return settingsResponse.Array[0].HardwareDriverSettings, nil -} - -func requestMilestoneJSON( - ctx context.Context, - requestURL string, - accessToken string, - skipTLSVerify bool, -) ([]byte, error) { - request, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - requestURL, - nil, - ) - if err != nil { - return nil, err - } - - request.Header.Set("Authorization", "Bearer "+accessToken) - request.Header.Set("Accept", "application/json") - - client := milestoneHTTPClient(skipTLSVerify) - - response, err := client.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 request failed: url=%s status=%d body=%s", - requestURL, - response.StatusCode, - truncateMilestoneLogBody(body), - ) - } - - return body, nil -} - func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) { var credentials milestoneCredentials @@ -1366,7 +480,10 @@ func milestoneHTTPClient(skipTLSVerify bool) *http.Client { return client } -func (s *Server) requestMilestoneToken(ctx context.Context, credentials milestoneCredentials) (milestoneTokenResponse, error) { +func (s *Server) requestMilestoneToken( + ctx context.Context, + credentials milestoneCredentials, +) (milestoneTokenResponse, error) { var tokenResponse milestoneTokenResponse form := url.Values{} @@ -1428,6 +545,26 @@ func (s *Server) requestMilestoneToken(ctx context.Context, credentials mileston return tokenResponse, nil } +func normalizeServerFoldersAgentSettings(scheme string, port string) (string, string) { + scheme = strings.TrimSpace(strings.ToLower(scheme)) + if scheme != "https" { + scheme = "http" + } + + port = strings.TrimSpace(strings.TrimPrefix(port, ":")) + if port == "" { + port = "8099" + } + + for _, char := range port { + if char < '0' || char > '9' { + return scheme, "8099" + } + } + + return scheme, port +} + func normalizeMilestoneHost(value string) string { value = strings.TrimSpace(value) value = strings.TrimRight(value, "/") diff --git a/backend/devices.go b/backend/devices.go index 15cabf5..21fd470 100644 --- a/backend/devices.go +++ b/backend/devices.go @@ -73,6 +73,7 @@ type MilestoneHardwareChildDevice struct { DisplayName string `json:"displayName"` ShortName string `json:"shortName"` Enabled bool `json:"enabled"` + RecordingEnabled bool `json:"recordingEnabled"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` } @@ -258,6 +259,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { 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), milestone_hardware_child_devices.created_at, milestone_hardware_child_devices.updated_at FROM milestone_hardware_child_devices @@ -290,6 +292,7 @@ func (s *Server) handleListDevices(w http.ResponseWriter, r *http.Request) { &childDevice.DisplayName, &childDevice.ShortName, &childDevice.Enabled, + &childDevice.RecordingEnabled, &childDevice.CreatedAt, &childDevice.UpdatedAt, ); err != nil { diff --git a/backend/external/README.md b/backend/external/README.md new file mode 100644 index 0000000..a166dba --- /dev/null +++ b/backend/external/README.md @@ -0,0 +1,45 @@ +# Server Folders mit Parametern + +## Backend-Proxy + +Der bestehende Frontend-Endpunkt bleibt: + +```http +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: + +```http +X-Server-Folders-Token: CHANGE-ME +``` + +## Agent starten + +```powershell +.\server-folders-agent.exe -port 8099 -roots "Milestone D=D:\Milestone;Milestone E=E:\Milestone" -token "CHANGE-ME" +``` + +Alternativ weiter per ENV: + +```powershell +$env:LISTEN_ADDR=":8099" +$env:SERVER_FOLDER_ROOTS="Milestone D=D:\Milestone;Milestone E=E:\Milestone" +$env:SERVER_FOLDER_AGENT_TOKEN="CHANGE-ME" + +.\server-folders-agent.exe +``` diff --git a/backend/external/agent_build.bat b/backend/external/agent_build.bat new file mode 100644 index 0000000..53fedcd --- /dev/null +++ b/backend/external/agent_build.bat @@ -0,0 +1,35 @@ +@echo off +setlocal + +cd /d "%~dp0" + +echo Entferne alten Build... +if exist server-folders-agent.exe del server-folders-agent.exe + +echo. +echo Baue server-folders-agent fuer Windows 11 x64... +echo. + +set GOOS=windows +set GOARCH=amd64 +set CGO_ENABLED=0 + +go build -o server-folders-agent.exe . + +if errorlevel 1 ( + echo. + echo Build fehlgeschlagen. + pause + exit /b 1 +) + +echo. +echo Build erfolgreich: server-folders-agent.exe +echo. +echo Beispiel: +echo server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "dein-token" +echo. + +pause + +endlocal diff --git a/backend/external/agent_main.go b/backend/external/agent_main.go new file mode 100644 index 0000000..eab7261 --- /dev/null +++ b/backend/external/agent_main.go @@ -0,0 +1,705 @@ +// backend\external\agent_main.go +// +// Eigenständiger Ordner-Agent für den Milestone-/Recording-Server. +// +// Endpunkte: +// GET /health +// GET /server-folders?rootType=storage&path=D:\Milestone +// POST /server-folders +// +// Start per Parameter: +// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME" +// +// Pflichtwerte: -storage-root, -archive-root und -token. Alternativ können die passenden ENV-Variablen gesetzt werden. +// +// Alternativ per ENV: +// LISTEN_ADDR=:8099 +// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage +// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive +// SERVER_FOLDER_AGENT_TOKEN=optional-geheimer-token +// +// Wenn SERVER_FOLDER_AGENT_TOKEN oder -token gesetzt ist, muss der Client senden: +// X-Server-Folders-Token: +// oder: +// Authorization: Bearer + +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "io/fs" + "log" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + "syscall" + "time" + "unsafe" +) + +const ( + defaultListenAddr = ":8099" + + listenAddrEnv = "LISTEN_ADDR" + serverStorageRootsEnv = "SERVER_STORAGE_ROOTS" + serverArchiveRootsEnv = "SERVER_ARCHIVE_ROOTS" + legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS" + agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN" +) + +type serverFolderRootType string + +const ( + serverFolderRootTypeStorage serverFolderRootType = "storage" + serverFolderRootTypeArchive serverFolderRootType = "archive" +) + +type appConfig struct { + ListenAddr string + StorageRoots string + ArchiveRoots string + Token string +} + +func (config appConfig) rootsForType(rootType serverFolderRootType) string { + if rootType == serverFolderRootTypeArchive { + return strings.TrimSpace(config.ArchiveRoots) + } + + return strings.TrimSpace(config.StorageRoots) +} + +type serverFolderRoot struct { + Label string `json:"label"` + Path string `json:"path"` +} + +type serverFolderNode struct { + Name string `json:"name"` + DisplayName string `json:"displayName,omitempty"` + Path string `json:"path"` + HasChildren bool `json:"hasChildren"` +} + +type listServerFoldersResponse struct { + RootType string `json:"rootType"` + Path string `json:"path"` + ParentPath string `json:"parentPath,omitempty"` + Roots []serverFolderRoot `json:"roots"` + Folders []serverFolderNode `json:"folders"` +} + +type createServerFolderRequest struct { + Path string `json:"path"` + ParentPath string `json:"parentPath"` + Name string `json:"name"` +} + +func main() { + config := readConfigFromFlagsAndEnv() + + mux := http.NewServeMux() + + mux.HandleFunc("GET /health", handleHealth) + mux.HandleFunc("GET /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { + handleListServerFolders(w, r, config) + })) + mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { + handleCreateServerFolder(w, r, config) + })) + + server := &http.Server{ + Addr: config.ListenAddr, + Handler: logRequests(mux), + ReadHeaderTimeout: 10 * time.Second, + } + + 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 enabled: %t", config.Token != "") + + if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("server failed: %v", err) + } +} + +func readConfigFromFlagsAndEnv() appConfig { + 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", "", "Required access token for X-Server-Folders-Token") + flag.Parse() + + listenAddr := strings.TrimSpace(*addrFlag) + if listenAddr == "" { + listenAddr = strings.TrimSpace(os.Getenv(listenAddrEnv)) + } + + port := strings.TrimSpace(*portFlag) + if listenAddr == "" && port != "" { + port = strings.TrimPrefix(port, ":") + listenAddr = ":" + port + } + + if listenAddr == "" { + listenAddr = defaultListenAddr + } + + legacyRoots := strings.TrimSpace(*legacyRootsFlag) + if legacyRoots == "" { + legacyRoots = strings.TrimSpace(os.Getenv(legacyFolderRootsEnv)) + } + + storageRoots := strings.TrimSpace(*storageRootsFlag) + if storageRoots == "" { + storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv)) + } + + archiveRoots := strings.TrimSpace(*archiveRootsFlag) + if archiveRoots == "" { + archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv)) + } + + token := strings.TrimSpace(*tokenFlag) + if token == "" { + token = strings.TrimSpace(os.Getenv(agentTokenEnv)) + } + + config := appConfig{ + ListenAddr: listenAddr, + StorageRoots: storageRoots, + ArchiveRoots: archiveRoots, + Token: token, + } + + validateRequiredConfig(config, legacyRoots) + + return config +} + +func validateRequiredConfig(config appConfig, legacyRoots string) { + missing := make([]string, 0, 3) + + if strings.TrimSpace(config.StorageRoots) == "" { + missing = append(missing, "-storage-root oder "+serverStorageRootsEnv) + } + + if strings.TrimSpace(config.ArchiveRoots) == "" { + missing = append(missing, "-archive-root oder "+serverArchiveRootsEnv) + } + + if strings.TrimSpace(config.Token) == "" { + missing = append(missing, "-token oder "+agentTokenEnv) + } + + if len(missing) == 0 { + return + } + + if strings.TrimSpace(legacyRoots) != "" { + log.Printf("Hinweis: -roots/%s wird nicht mehr als Ersatz fuer -storage-root und -archive-root akzeptiert.", legacyFolderRootsEnv) + } + + log.Fatalf( + `Pflichtparameter fehlen: %s. Beispiel: server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"`, + strings.Join(missing, ", "), + ) +} + +func handleHealth(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func handleListServerFolders(w http.ResponseWriter, r *http.Request, config appConfig) { + rootType := serverFolderRootTypeFromRequest(r) + + roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + requestedPath := strings.TrimSpace(r.URL.Query().Get("path")) + if requestedPath == "" { + requestedPath = roots[0].Path + } + + targetPath, err := cleanAbsolutePath(requestedPath) + if err != nil { + writeError(w, http.StatusBadRequest, "Ungültiger Pfad") + return + } + + if !isPathAllowed(targetPath, roots) { + writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben") + return + } + + resolvedTargetPath, err := resolveExistingPath(targetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden") + return + } + + writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden") + return + } + + if !isPathAllowed(resolvedTargetPath, roots) { + writeError(w, http.StatusForbidden, "Pfad verlässt den freigegebenen Bereich") + return + } + + info, err := os.Stat(resolvedTargetPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden") + return + } + + writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden") + return + } + + if !info.IsDir() { + writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner") + return + } + + entries, err := os.ReadDir(resolvedTargetPath) + if err != nil { + writeError(w, http.StatusForbidden, "Ordnerinhalt konnte nicht gelesen werden") + return + } + + folders := make([]serverFolderNode, 0, len(entries)) + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + childPath := filepath.Join(resolvedTargetPath, entry.Name()) + childPath, err = cleanAbsolutePath(childPath) + if err != nil { + continue + } + + if !isPathAllowed(childPath, roots) { + continue + } + + fileSystemName := entry.Name() + + folders = append(folders, serverFolderNode{ + Name: fileSystemName, + DisplayName: windowsDisplayNameForPath(childPath, fileSystemName), + Path: childPath, + HasChildren: folderHasVisibleSubdirectories(childPath, roots), + }) + } + + sort.SliceStable(folders, func(i int, j int) bool { + return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name) + }) + + writeJSON(w, http.StatusOK, listServerFoldersResponse{ + RootType: string(rootType), + Path: resolvedTargetPath, + ParentPath: allowedParentPath(resolvedTargetPath, roots), + Roots: roots, + Folders: folders, + }) +} + +func handleCreateServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) { + rootType := serverFolderRootTypeFromRequest(r) + + roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + var input createServerFolderRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + targetPath, err := resolveCreateFolderTarget(input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + if !isPathAllowed(targetPath, roots) { + writeError(w, http.StatusForbidden, "Zielpfad ist nicht freigegeben") + return + } + + parentPath := filepath.Dir(targetPath) + resolvedParentPath, err := resolveExistingPath(parentPath) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + writeError(w, http.StatusNotFound, "Übergeordneter Ordner wurde nicht gefunden") + return + } + + writeError(w, http.StatusBadRequest, "Übergeordneter Ordner konnte nicht geprüft werden") + return + } + + if !isPathAllowed(resolvedParentPath, roots) { + writeError(w, http.StatusForbidden, "Übergeordneter Ordner ist nicht freigegeben") + return + } + + parentInfo, err := os.Stat(resolvedParentPath) + if err != nil { + writeError(w, http.StatusBadRequest, "Übergeordneter Ordner konnte nicht gelesen werden") + return + } + + if !parentInfo.IsDir() { + writeError(w, http.StatusBadRequest, "Übergeordneter Pfad ist kein Ordner") + return + } + + if err := os.Mkdir(targetPath, 0750); err != nil { + if errors.Is(err, fs.ErrExist) { + writeError(w, http.StatusConflict, "Ordner existiert bereits") + return + } + + writeError(w, http.StatusForbidden, "Ordner konnte nicht erstellt werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "folder": serverFolderNode{ + Name: filepath.Base(targetPath), + DisplayName: windowsDisplayNameForPath(targetPath, filepath.Base(targetPath)), + Path: targetPath, + HasChildren: false, + }, + }) +} + +func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType { + value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType"))) + if value == "" { + value = strings.TrimSpace(strings.ToLower(r.URL.Query().Get("type"))) + } + + switch value { + case "archive", "archive-root", "archive_root": + return serverFolderRootTypeArchive + default: + return serverFolderRootTypeStorage + } +} + +func getConfiguredServerFolderRoots(rawValue string, rootType serverFolderRootType) ([]serverFolderRoot, error) { + rawValue = strings.TrimSpace(rawValue) + if rawValue == "" { + return nil, fmt.Errorf("%s-root fehlt", rootType) + } + + parts := strings.Split(rawValue, ";") + roots := make([]serverFolderRoot, 0, len(parts)) + + for _, part := range parts { + part = strings.TrimSpace(part) + if part == "" { + continue + } + + label := "" + pathValue := part + + if before, after, found := strings.Cut(part, "="); found { + label = strings.TrimSpace(before) + pathValue = strings.TrimSpace(after) + } + + rootPath, err := cleanAbsolutePath(pathValue) + if err != nil { + return nil, fmt.Errorf("ungültiger Root-Pfad: %s", pathValue) + } + + if label == "" { + label = rootPath + } + + roots = append(roots, serverFolderRoot{ + Label: label, + Path: rootPath, + }) + } + + if len(roots) == 0 { + return nil, fmt.Errorf("keine gültigen Root-Pfade konfiguriert") + } + + return roots, nil +} + +func cleanAbsolutePath(value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("Pfad fehlt") + } + + cleaned := filepath.Clean(value) + + absolute, err := filepath.Abs(cleaned) + if err != nil { + return "", err + } + + return absolute, nil +} + +func resolveExistingPath(value string) (string, error) { + cleaned, err := cleanAbsolutePath(value) + if err != nil { + return "", err + } + + resolved, err := filepath.EvalSymlinks(cleaned) + if err != nil { + return "", err + } + + return cleanAbsolutePath(resolved) +} + +func resolveCreateFolderTarget(input createServerFolderRequest) (string, error) { + if strings.TrimSpace(input.Path) != "" { + return cleanAbsolutePath(input.Path) + } + + parentPath := strings.TrimSpace(input.ParentPath) + folderName := strings.TrimSpace(input.Name) + + if parentPath == "" { + return "", fmt.Errorf("parentPath fehlt") + } + + if !isSafeSingleFolderName(folderName) { + return "", fmt.Errorf("Ordnername ist ungültig") + } + + return cleanAbsolutePath(filepath.Join(parentPath, folderName)) +} + +func isSafeSingleFolderName(value string) bool { + value = strings.TrimSpace(value) + + if value == "" || value == "." || value == ".." { + return false + } + + if strings.ContainsAny(value, `/\`) { + return false + } + + if strings.ContainsAny(value, `<>:"|?*`) { + return false + } + + return true +} + +func isPathAllowed(pathValue string, roots []serverFolderRoot) bool { + pathValue, err := cleanAbsolutePath(pathValue) + if err != nil { + return false + } + + for _, root := range roots { + rootPath, err := cleanAbsolutePath(root.Path) + if err != nil { + continue + } + + if pathWithinRoot(pathValue, rootPath) { + return true + } + } + + return false +} + +func pathWithinRoot(pathValue string, rootPath string) bool { + rel, err := filepath.Rel(rootPath, pathValue) + if err != nil { + return false + } + + return rel == "." || + (rel != ".." && + !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && + !filepath.IsAbs(rel)) +} + +func allowedParentPath(pathValue string, roots []serverFolderRoot) string { + parentPath := filepath.Dir(pathValue) + + if parentPath == pathValue { + return "" + } + + if !isPathAllowed(parentPath, roots) { + return "" + } + + return parentPath +} + +func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot) bool { + entries, err := os.ReadDir(pathValue) + if err != nil { + return false + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + childPath, err := cleanAbsolutePath(filepath.Join(pathValue, entry.Name())) + if err != nil { + continue + } + + if isPathAllowed(childPath, roots) { + return true + } + } + + return false +} + +const ( + shgfiDisplayName = 0x000000200 +) + +type shFileInfo struct { + HIcon uintptr + IIcon int32 + DwAttributes uint32 + SzDisplayName [260]uint16 + SzTypeName [80]uint16 +} + +var ( + shell32DLL = syscall.NewLazyDLL("shell32.dll") + procSHGetFileInfo = shell32DLL.NewProc("SHGetFileInfoW") +) + +func windowsDisplayNameForPath(pathValue string, fallback string) string { + pathValue = strings.TrimSpace(pathValue) + fallback = strings.TrimSpace(fallback) + + if pathValue == "" { + return fallback + } + + pathPointer, err := syscall.UTF16PtrFromString(pathValue) + if err != nil { + return fallback + } + + var info shFileInfo + result, _, _ := procSHGetFileInfo.Call( + uintptr(unsafe.Pointer(pathPointer)), + 0, + uintptr(unsafe.Pointer(&info)), + unsafe.Sizeof(info), + shgfiDisplayName, + ) + + if result == 0 { + return fallback + } + + displayName := strings.TrimSpace(syscall.UTF16ToString(info.SzDisplayName[:])) + if displayName == "" { + return fallback + } + + return displayName +} + +func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc { + requiredToken = strings.TrimSpace(requiredToken) + + return func(w http.ResponseWriter, r *http.Request) { + if requiredToken == "" { + next(w, r) + return + } + + token := strings.TrimSpace(r.Header.Get("X-Server-Folders-Token")) + + if token == "" { + authHeader := strings.TrimSpace(r.Header.Get("Authorization")) + if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") { + token = strings.TrimSpace(authHeader[len("bearer "):]) + } + } + + if token != requiredToken { + writeError(w, http.StatusUnauthorized, "Unauthorized") + return + } + + next(w, r) + } +} + +func logRequests(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log.Printf("%s %s from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr) + next.ServeHTTP(w, r) + }) +} + +func readJSON(r *http.Request, target any) error { + defer r.Body.Close() + + decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) + decoder.DisallowUnknownFields() + + return decoder.Decode(target) +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + + if err := json.NewEncoder(w).Encode(value); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]any{ + "error": message, + }) +} diff --git a/backend/go.mod b/backend/go.mod index 67bd43b..5135773 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,5 @@ +// backend\go.mod + module main go 1.26.2 @@ -9,13 +11,6 @@ require ( ) require ( - github.com/jackc/chunkreader/v2 v2.0.1 // indirect - github.com/jackc/pgio v1.0.0 // indirect - github.com/jackc/pgproto3/v2 v2.3.3 // indirect -) - -require ( - github.com/jackc/pgconn v1.14.3 github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/puddle/v2 v2.2.2 // indirect diff --git a/backend/go.sum b/backend/go.sum index 419e055..94fa715 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -3,17 +3,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= -github.com/jackc/chunkreader/v2 v2.0.0/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8= -github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk= -github.com/jackc/pgconn v1.14.3 h1:bVoTr12EGANZz66nZPkMInAV/KHD2TxH9npjXXgiB3w= -github.com/jackc/pgconn v1.14.3/go.mod h1:RZbme4uasqzybK2RK5c65VsHxoyaml09lx3tXOcO/VM= -github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE= -github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgproto3/v2 v2.3.3 h1:1HLSx5H+tXR9pW3in3zaztoEwQYRC9SQaYUHjTSUOag= -github.com/jackc/pgproto3/v2 v2.3.3/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgx/v5 v5.9.2 h1:3ZhOzMWnR4yJ+RW1XImIPsD1aNSz4T4fyP7zlQb56hw= @@ -26,7 +17,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= @@ -37,7 +27,6 @@ golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/milestone_api.go b/backend/milestone_api.go index da2be1a..33a9b2e 100644 --- a/backend/milestone_api.go +++ b/backend/milestone_api.go @@ -24,6 +24,7 @@ type milestoneRuntimeConfig struct { TokenType string SkipTLSVerify bool TokenExpiresAt *time.Time + ServerID string } type milestoneHardwarePatchDevice struct { @@ -59,6 +60,12 @@ type milestoneChildPatchDevice struct { Enabled bool } +type milestoneHardwareUpdateEnvelope struct { + Data map[string]any `json:"data"` +} + +const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute + func (s *Server) getMilestoneChildPatchDevice( ctx context.Context, deviceID string, @@ -179,14 +186,6 @@ func milestoneCollectionForChildDeviceType(deviceType string) (string, error) { return "cameras", nil case "microphone", "microphones": return "microphones", nil - case "input", "inputs": - return "inputs", nil - case "output", "outputs": - return "outputs", nil - case "speaker", "speakers": - return "speakers", nil - case "metadata": - return "metadata", nil default: return "", fmt.Errorf("unsupported milestone child device type: %s", deviceType) } @@ -293,8 +292,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re if requestedDisplayName != nil { nextDisplayName = strings.TrimSpace(*requestedDisplayName) - - // Wenn der Client einen Namen sendet, immer an Milestone weitergeben. payload["name"] = nextDisplayName if nextDisplayName != strings.TrimSpace(device.MilestoneDisplayName) { @@ -340,7 +337,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re nextIPAddress = strings.TrimSpace(addressValue) } - // Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben. payload["address"] = nextAddress if nextIPAddress != strings.TrimSpace(device.IPAddress) { @@ -366,8 +362,6 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re if input.Enabled != nil { nextEnabled = *input.Enabled - - // Wenn der Client den Status sendet, immer an Milestone weitergeben. payload["enabled"] = nextEnabled if nextEnabled != device.MilestoneEnabled { @@ -489,29 +483,29 @@ func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Re _, err = tx.Exec( r.Context(), ` - UPDATE milestone_hardware_child_devices - SET - name = CASE - WHEN name = $2 THEN $3 - WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1) - WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1) - ELSE name - END, - display_name = CASE - WHEN display_name = $2 THEN $3 - WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1) - WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1) - ELSE display_name - END, - short_name = CASE - WHEN short_name = $2 THEN $3 - WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1) - WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1) - ELSE short_name - END, - updated_at = now() - WHERE milestone_hardware_id = $1 - `, + UPDATE milestone_hardware_child_devices + SET + name = CASE + WHEN name = $2 THEN $3 + WHEN name LIKE $2 || ' - %' THEN $3 || substring(name FROM char_length($2) + 1) + WHEN name LIKE $2 || ' %' THEN $3 || substring(name FROM char_length($2) + 1) + ELSE name + END, + display_name = CASE + WHEN display_name = $2 THEN $3 + WHEN display_name LIKE $2 || ' - %' THEN $3 || substring(display_name FROM char_length($2) + 1) + WHEN display_name LIKE $2 || ' %' THEN $3 || substring(display_name FROM char_length($2) + 1) + ELSE display_name + END, + short_name = CASE + WHEN short_name = $2 THEN $3 + WHEN short_name LIKE $2 || ' - %' THEN $3 || substring(short_name FROM char_length($2) + 1) + WHEN short_name LIKE $2 || ' %' THEN $3 || substring(short_name FROM char_length($2) + 1) + ELSE short_name + END, + updated_at = now() + WHERE milestone_hardware_id = $1 + `, device.MilestoneHardwareID, device.MilestoneDisplayName, nextDisplayName, @@ -636,12 +630,6 @@ func normalizeMilestonePort(value string) string { return value } -type milestoneHardwareUpdateEnvelope struct { - Data map[string]any `json:"data"` -} - -const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute - func (s *Server) getMilestoneRuntimeConfig( ctx context.Context, ) (milestoneRuntimeConfig, error) { @@ -687,7 +675,8 @@ func (s *Server) loadMilestoneRuntimeConfig( COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''), COALESCE(token_type, ''), skip_tls_verify, - token_expires_at + token_expires_at, + COALESCE(server_id, '') FROM milestone_settings WHERE id = 1 LIMIT 1 @@ -699,6 +688,7 @@ func (s *Server) loadMilestoneRuntimeConfig( &config.TokenType, &config.SkipTLSVerify, &tokenExpiresAt, + &config.ServerID, ) if err != nil { return config, err @@ -975,48 +965,56 @@ func putMilestoneHardware( return nil } -func replaceMilestoneDeviceBaseName( - cameraName string, - oldHardwareName string, - newHardwareName string, -) (string, bool) { - cameraName = strings.TrimSpace(cameraName) - oldHardwareName = strings.TrimSpace(oldHardwareName) - newHardwareName = strings.TrimSpace(newHardwareName) +func getMilestoneNamedDevice( + ctx context.Context, + config milestoneRuntimeConfig, + collection string, + deviceID string, +) (map[string]any, error) { + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/" + collection + "/" + url.PathEscape(deviceID) - if cameraName == "" || newHardwareName == "" { - return cameraName, false + request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) + if err != nil { + return nil, err } - if oldHardwareName != "" { - if cameraName == oldHardwareName { - return newHardwareName, true - } - - if strings.HasPrefix(cameraName, oldHardwareName) { - suffix := strings.TrimPrefix(cameraName, oldHardwareName) - - if suffix == "" || - strings.HasPrefix(suffix, " ") || - strings.HasPrefix(suffix, "-") || - strings.HasPrefix(suffix, "_") { - return newHardwareName + suffix, true - } - } + tokenType := strings.TrimSpace(config.TokenType) + if tokenType == "" { + tokenType = "Bearer" } - // Fallback für Fälle, in denen die Hardware bereits umbenannt wurde, - // die Kamera aber noch z. B. "Camera_102_Test - Kamera 1" heißt. - if baseName, suffix, found := strings.Cut(cameraName, " - "); found { - baseName = strings.TrimSpace(baseName) - suffix = strings.TrimSpace(suffix) + request.Header.Set("Authorization", tokenType+" "+config.AccessToken) + request.Header.Set("Accept", "application/json") - if baseName != "" && suffix != "" && baseName != newHardwareName { - return newHardwareName + " - " + suffix, true - } + client := milestoneHTTPClient(config.SkipTLSVerify) + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return nil, err } - return cameraName, false + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("milestone %s get failed: status=%d body=%s", collection, response.StatusCode, string(body)) + } + + 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 milestoneStringValue(value any) string { @@ -1028,428 +1026,30 @@ func milestoneStringValue(value any) string { return strings.TrimSpace(text) } -type milestoneNamedDevice struct { - Collection string - ID string - Name string - DisplayName string - ShortName string - Enabled bool -} - -func milestoneItemEnabledValue(item map[string]any) bool { - enabled := true - - if value, ok := item["enabled"].(bool); ok { - enabled = value - } - - if value, ok := item["disabled"].(bool); ok { - enabled = !value - } - - return enabled -} - -func milestoneNamedDevicesFromHardware(hardware map[string]any) []milestoneNamedDevice { - devices := []milestoneNamedDevice{} - - devices = append( - devices, - milestoneNamedDevicesFromHardwareCollection(hardware, "cameras")..., - ) - - devices = append( - devices, - milestoneNamedDevicesFromHardwareCollection(hardware, "microphones")..., - ) - - devices = append( - devices, - milestoneNamedDevicesFromHardwareCollection(hardware, "inputs")..., - ) - - devices = append( - devices, - milestoneNamedDevicesFromHardwareCollection(hardware, "outputs")..., - ) - - devices = append( - devices, - milestoneNamedDevicesFromHardwareCollection(hardware, "speakers")..., - ) - - devices = append( - devices, - milestoneNamedDevicesFromHardwareCollection(hardware, "metadata")..., - ) - - return devices -} - -func milestoneNamedDevicesFromHardwareCollectionOnly( - hardware map[string]any, - collection string, -) []milestoneNamedDevice { - return milestoneNamedDevicesFromHardwareCollection(hardware, collection) -} - -func milestoneNamedDevicesFromHardwareCollection( - hardware map[string]any, - collection string, -) []milestoneNamedDevice { - rawItems, ok := hardware[collection].([]any) - if !ok { - return nil - } - - devices := make([]milestoneNamedDevice, 0, len(rawItems)) - - for _, rawItem := range rawItems { - item, ok := rawItem.(map[string]any) - if !ok { - continue - } - - id := milestoneNamedDeviceID(item) - if id == "" { - continue - } - - name := milestoneStringValue(item["name"]) - displayName := milestoneStringValue(item["displayName"]) - shortName := milestoneStringValue(item["shortName"]) - - if name == "" { - name = displayName - } - if name == "" { - name = shortName - } - if displayName == "" { - displayName = name - } - if shortName == "" { - shortName = name - } - - if name == "" { - continue - } - - enabled := milestoneItemEnabledValue(item) - - devices = append(devices, milestoneNamedDevice{ - Collection: collection, - ID: id, - Name: name, - DisplayName: displayName, - ShortName: shortName, - Enabled: enabled, - }) - } - - return devices -} - -func mergeMilestoneNamedDevices(groups ...[]milestoneNamedDevice) []milestoneNamedDevice { - result := []milestoneNamedDevice{} - seen := map[string]bool{} - - for _, group := range groups { - for _, device := range group { - key := device.Collection + ":" + device.ID - if device.ID == "" || seen[key] { - continue - } - - seen[key] = true - result = append(result, device) - } - } - - return result -} - -func requestMilestoneNamedDevicesByHardware( - ctx context.Context, - config milestoneRuntimeConfig, - collection string, - hardwareID string, -) ([]milestoneNamedDevice, error) { - switch collection { - case "cameras", "microphones", "inputs", "outputs", "speakers", "metadata": +func milestoneBoolValue(value any) bool { + switch typedValue := value.(type) { + case bool: + return typedValue default: - return nil, fmt.Errorf("unsupported milestone device collection: %s", collection) + return false } - - requestURL := strings.TrimRight(config.Host, "/") + - "/API/rest/v1/" + - collection + - "?disabled" - - request, err := http.NewRequestWithContext( - ctx, - http.MethodGet, - requestURL, - nil, - ) - if err != nil { - return nil, err - } - - tokenType := strings.TrimSpace(config.TokenType) - if tokenType == "" { - tokenType = "Bearer" - } - - request.Header.Set("Authorization", tokenType+" "+config.AccessToken) - request.Header.Set("Accept", "application/json") - - response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) - if err != nil { - return nil, err - } - defer response.Body.Close() - - responseBody, _ := io.ReadAll(response.Body) - - if response.StatusCode < 200 || response.StatusCode >= 300 { - return nil, newAppErrorf( - "milestone child device list failed", - LogFields{ - "statusCode": response.StatusCode, - "responseBody": string(responseBody), - "collection": collection, - "hardwareId": hardwareID, - }, - "milestone child device list failed: collection=%s hardwareId=%s status=%d body=%s", - collection, - hardwareID, - response.StatusCode, - string(responseBody), - ) - } - - return milestoneNamedDevicesFromListResponse(responseBody, collection, hardwareID) } -func milestoneNamedDevicesFromListResponse( - body []byte, - collection string, - hardwareID string, -) ([]milestoneNamedDevice, error) { - var decoded map[string]any - - if err := json.Unmarshal(body, &decoded); err != nil { - return nil, err - } - - var rawItems []any - - if array, ok := decoded["array"].([]any); ok { - rawItems = array - } else if array, ok := decoded["data"].([]any); ok { - rawItems = array - } else if data, ok := decoded["data"].(map[string]any); ok { - if array, ok := data["array"].([]any); ok { - rawItems = array - } - } - - if len(rawItems) == 0 { - return nil, nil - } - - devices := make([]milestoneNamedDevice, 0, len(rawItems)) - hardwareID = strings.TrimSpace(hardwareID) - - for _, rawItem := range rawItems { - item, ok := rawItem.(map[string]any) - if !ok { - continue - } - - parentID := milestoneNamedDeviceParentID(item) - if parentID != hardwareID { - continue - } - - id := milestoneNamedDeviceID(item) - if id == "" { - continue - } - - displayName := milestoneStringValue(item["displayName"]) - shortName := milestoneStringValue(item["shortName"]) - name := milestoneStringValue(item["name"]) - - if name == "" { - name = displayName - } - if name == "" { - name = shortName - } - if displayName == "" { - displayName = name - } - if shortName == "" { - shortName = name - } - if name == "" { - continue - } - - enabled := milestoneItemEnabledValue(item) - - devices = append(devices, milestoneNamedDevice{ - Collection: collection, - ID: id, - Name: name, - DisplayName: displayName, - ShortName: shortName, - Enabled: enabled, - }) - } - - return devices, nil -} - -func milestoneNamedDeviceParentID(item map[string]any) string { - relations, ok := item["relations"].(map[string]any) - if !ok { - return "" - } - - parent, ok := relations["parent"].(map[string]any) - if !ok { - return "" - } - - return milestoneStringValue(parent["id"]) -} - -func milestoneNamedDeviceID(item map[string]any) string { - if id := milestoneStringValue(item["id"]); id != "" { - return id - } - - relations, ok := item["relations"].(map[string]any) - if !ok { - return "" - } - - self, ok := relations["self"].(map[string]any) - if !ok { - return "" - } - - return milestoneStringValue(self["id"]) -} - -func updateMilestoneChildDeviceBaseNames( - ctx context.Context, - config milestoneRuntimeConfig, - devices []milestoneNamedDevice, - oldHardwareName string, - newHardwareName string, -) error { - for _, device := range devices { - nextName, changed := replaceMilestoneDeviceBaseName( - device.Name, - oldHardwareName, - newHardwareName, - ) - if !changed { - continue - } - - if err := patchMilestoneNamedDevice( - ctx, - config, - device.Collection, - device.ID, - map[string]any{ - "name": nextName, - "displayName": nextName, - "shortName": nextName, - }, - ); err != nil { - return err - } - } - - return nil -} - -func patchMilestoneNamedDevice( - ctx context.Context, - config milestoneRuntimeConfig, - collection string, - deviceID string, - payload map[string]any, -) error { - switch collection { - case "cameras", "microphones": +func milestoneIntPointerValue(value any) (*int, bool) { + switch typedValue := value.(type) { + case int: + v := typedValue + return &v, true + case int32: + v := int(typedValue) + return &v, true + case int64: + v := int(typedValue) + return &v, true + case float64: + v := int(typedValue) + return &v, true default: - return fmt.Errorf("unsupported milestone device collection: %s", collection) + return nil, false } - - body, err := json.Marshal(payload) - if err != nil { - return err - } - - requestURL := strings.TrimRight(config.Host, "/") + - "/API/rest/v1/" + - collection + - "/" + - url.PathEscape(deviceID) - - request, err := http.NewRequestWithContext( - ctx, - http.MethodPatch, - requestURL, - bytes.NewReader(body), - ) - if err != nil { - return err - } - - tokenType := strings.TrimSpace(config.TokenType) - if tokenType == "" { - tokenType = "Bearer" - } - - request.Header.Set("Authorization", tokenType+" "+config.AccessToken) - request.Header.Set("Accept", "application/json") - request.Header.Set("Content-Type", "application/json") - - 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 newAppErrorf( - "milestone child device patch failed", - LogFields{ - "statusCode": response.StatusCode, - "responseBody": string(responseBody), - "collection": collection, - "deviceId": deviceID, - "payload": payload, - }, - "milestone child device patch failed: collection=%s id=%s status=%d body=%s", - collection, - deviceID, - response.StatusCode, - string(responseBody), - ) - } - - return nil } diff --git a/backend/milestone_cameras.go b/backend/milestone_cameras.go new file mode 100644 index 0000000..e6f5786 --- /dev/null +++ b/backend/milestone_cameras.go @@ -0,0 +1,457 @@ +// backend\milestone_cameras.go + +package main + +import ( + "context" + "errors" + "net/http" + "strings" + + "github.com/jackc/pgx/v5" +) + +type milestoneCameraPatchDevice struct { + DeviceID string + InventoryNumber string + ChildID string + MilestoneHardwareID string + MilestoneDeviceID string + DeviceType string + Enabled bool + RecordingEnabled bool +} + +type milestoneRecordingStorageRef struct { + ID string `json:"id"` + DisplayName string `json:"displayName,omitempty"` + Name string `json:"name,omitempty"` + DiskPath string `json:"diskPath,omitempty"` + Mounted bool `json:"mounted,omitempty"` +} + +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"` +} + +type milestoneCameraDetail struct { + ID string `json:"id"` + MilestoneDeviceID string `json:"milestoneDeviceId"` + MilestoneHardwareID string `json:"milestoneHardwareId"` + Enabled bool `json:"enabled"` + RecordingEnabled bool `json:"recordingEnabled"` + RecordingFramerate *int `json:"recordingFramerate"` + RecordKeyframesOnly bool `json:"recordKeyframesOnly"` + RecordingStorage *milestoneRecordingStorageRef `json:"recordingStorage,omitempty"` + ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"` + Streams []milestoneCameraStream `json:"streams"` +} + +type updateMilestoneCameraStreamRequest 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"` +} + +type updateMilestoneCameraDetailsRequest struct { + Enabled *bool `json:"enabled"` + RecordingEnabled *bool `json:"recordingEnabled"` + RecordingFramerate *int `json:"recordingFramerate"` + RecordKeyframesOnly *bool `json:"recordKeyframesOnly"` + RecordingStorageID *string `json:"recordingStorageId"` + Streams []updateMilestoneCameraStreamRequest `json:"streams"` +} + +func (s *Server) getMilestoneCameraPatchDevice( + ctx context.Context, + deviceID string, + childID string, +) (milestoneCameraPatchDevice, error) { + var camera milestoneCameraPatchDevice + + err := s.db.QueryRow( + ctx, + ` + SELECT + devices.id::TEXT, + devices.inventory_number, + 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.enabled, + COALESCE(milestone_hardware_child_devices.recording_enabled, false) + FROM milestone_hardware_child_devices + INNER JOIN devices + ON devices.milestone_hardware_id = milestone_hardware_child_devices.milestone_hardware_id + WHERE devices.id = $1 + AND milestone_hardware_child_devices.id = $2 + LIMIT 1 + `, + deviceID, + childID, + ).Scan( + &camera.DeviceID, + &camera.InventoryNumber, + &camera.ChildID, + &camera.MilestoneHardwareID, + &camera.MilestoneDeviceID, + &camera.DeviceType, + &camera.Enabled, + &camera.RecordingEnabled, + ) + + return camera, err +} + +func (s *Server) handleGetMilestoneCameraDetails(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 + } + + if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" && + strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" { + writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + cameraData, err := getMilestoneNamedDevice( + r.Context(), + config, + "cameras", + cameraDevice.MilestoneDeviceID, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden") + return + } + + archiveStorages := []milestoneArchiveStorage{} + var currentStorage *milestoneStorage + + if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok { + recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) + if recordingStorageID != "" { + currentStorage, err = getMilestoneStorage( + r.Context(), + config, + recordingStorageID, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Storage konnte nicht geladen werden") + return + } + + archiveStorages, err = getMilestoneArchiveStorages( + r.Context(), + config, + recordingStorageID, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Archive-Storage konnte nicht geladen werden") + return + } + } + } + + camera := mapMilestoneCameraDetail(cameraData, cameraDevice, currentStorage, archiveStorages) + + writeJSON(w, http.StatusOK, map[string]any{ + "camera": camera, + }) +} + +func (s *Server) handleUpdateMilestoneCameraDetails(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 + } + + if strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "camera" && + strings.TrimSpace(strings.ToLower(cameraDevice.DeviceType)) != "cameras" { + writeError(w, http.StatusBadRequest, "Das Child-Device ist keine Kamera") + return + } + + var input updateMilestoneCameraDetailsRequest + if err := readJSON(r, &input); 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 + } + + currentCamera, err := getMilestoneNamedDevice( + r.Context(), + config, + "cameras", + cameraDevice.MilestoneDeviceID, + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht geladen werden") + return + } + + if input.Enabled != nil { + currentCamera["enabled"] = *input.Enabled + } + + if input.RecordingEnabled != nil { + currentCamera["recordingEnabled"] = *input.RecordingEnabled + } + + if input.RecordingFramerate != nil { + currentCamera["recordingFramerate"] = *input.RecordingFramerate + } + + if input.RecordKeyframesOnly != nil { + currentCamera["recordKeyframesOnly"] = *input.RecordKeyframesOnly + } + + if input.RecordingStorageID != nil { + recordingStorageID := strings.TrimSpace(*input.RecordingStorageID) + if recordingStorageID == "" { + currentCamera["recordingStorage"] = nil + } else { + currentCamera["recordingStorage"] = map[string]any{ + "type": "storages", + "id": recordingStorageID, + } + } + } + + applyMilestoneCameraStreamUpdates(currentCamera, input.Streams) + + if err := patchMilestoneNamedDevice( + r.Context(), + config, + "cameras", + cameraDevice.MilestoneDeviceID, + currentCamera, + ); err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Kamera konnte nicht aktualisiert werden") + return + } + + _, err = s.db.Exec( + r.Context(), + ` + UPDATE milestone_hardware_child_devices + SET + enabled = COALESCE($2, enabled), + recording_enabled = COALESCE($3, recording_enabled), + recording_storage_id = COALESCE($4, recording_storage_id), + updated_at = now() + WHERE id = $1 + `, + cameraDevice.ChildID, + input.Enabled, + input.RecordingEnabled, + input.RecordingStorageID, + ) + + if err != nil { + writeError(w, http.StatusInternalServerError, "Milestone-Kamera konnte lokal nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func mapMilestoneCameraDetail( + cameraData map[string]any, + cameraDevice milestoneCameraPatchDevice, + currentStorage *milestoneStorage, + archiveStorages []milestoneArchiveStorage, +) milestoneCameraDetail { + detail := milestoneCameraDetail{ + ID: cameraDevice.ChildID, + MilestoneDeviceID: cameraDevice.MilestoneDeviceID, + MilestoneHardwareID: cameraDevice.MilestoneHardwareID, + Enabled: milestoneBoolValue(cameraData["enabled"]), + RecordingEnabled: milestoneBoolValue(cameraData["recordingEnabled"]), + RecordKeyframesOnly: milestoneBoolValue(cameraData["recordKeyframesOnly"]), + Streams: extractMilestoneCameraStreams(cameraData), + ArchiveStorages: archiveStorages, + } + + if value, ok := milestoneIntPointerValue(cameraData["recordingFramerate"]); ok { + detail.RecordingFramerate = value + } + + if currentStorage != nil && strings.TrimSpace(currentStorage.ID) != "" { + detail.RecordingStorage = &milestoneRecordingStorageRef{ + ID: currentStorage.ID, + DisplayName: currentStorage.DisplayName, + Name: currentStorage.Name, + DiskPath: currentStorage.DiskPath, + Mounted: currentStorage.Mounted, + } + } else if recordingStorage, ok := cameraData["recordingStorage"].(map[string]any); ok { + recordingStorageID := strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) + if recordingStorageID != "" { + detail.RecordingStorage = &milestoneRecordingStorageRef{ + ID: recordingStorageID, + } + } + } + + return detail +} + +func extractMilestoneCameraStreams(cameraData map[string]any) []milestoneCameraStream { + settings, ok := cameraData["settings"].([]any) + if !ok { + return []milestoneCameraStream{} + } + + streams := []milestoneCameraStream{} + + for _, rawSetting := range settings { + setting, ok := rawSetting.(map[string]any) + if !ok { + continue + } + + rawStreams, ok := setting["stream"].([]any) + if !ok { + continue + } + + for index, rawStream := range rawStreams { + streamMap, ok := rawStream.(map[string]any) + if !ok { + continue + } + + stream := milestoneCameraStream{ + Index: index, + Codec: milestoneStringValue(streamMap["codec"]), + Resolution: milestoneStringValue(streamMap["resolution"]), + EdgeStorageSupported: milestoneBoolValue(streamMap["edgeStorageSupported"]), + } + + if value, ok := milestoneIntPointerValue(streamMap["FPS"]); ok { + stream.FPS = value + } + + if value, ok := milestoneIntPointerValue(streamMap["jPEGQuality"]); ok { + stream.JPEGQuality = value + } + + if value, ok := milestoneIntPointerValue(streamMap["quality"]); ok { + stream.Quality = value + } + + streams = append(streams, stream) + } + } + + return streams +} + +func applyMilestoneCameraStreamUpdates( + cameraData map[string]any, + streamUpdates []updateMilestoneCameraStreamRequest, +) { + if len(streamUpdates) == 0 { + return + } + + settings, ok := cameraData["settings"].([]any) + if !ok { + return + } + + updatesByIndex := map[int]updateMilestoneCameraStreamRequest{} + for _, streamUpdate := range streamUpdates { + updatesByIndex[streamUpdate.Index] = streamUpdate + } + + for _, rawSetting := range settings { + setting, ok := rawSetting.(map[string]any) + if !ok { + continue + } + + rawStreams, ok := setting["stream"].([]any) + if !ok { + continue + } + + for index, rawStream := range rawStreams { + streamMap, ok := rawStream.(map[string]any) + if !ok { + continue + } + + update, exists := updatesByIndex[index] + if !exists { + continue + } + + streamMap["codec"] = update.Codec + streamMap["resolution"] = update.Resolution + + if update.FPS != nil { + streamMap["FPS"] = *update.FPS + } + + if update.JPEGQuality != nil { + streamMap["jPEGQuality"] = *update.JPEGQuality + } + + if update.Quality != nil { + streamMap["quality"] = *update.Quality + } + } + } +} diff --git a/backend/milestone_child_devices.go b/backend/milestone_child_devices.go new file mode 100644 index 0000000..2f05418 --- /dev/null +++ b/backend/milestone_child_devices.go @@ -0,0 +1,481 @@ +// backend\milestone_child_devices.go + +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" +) + +type milestoneNamedDevice struct { + Collection string + ID string + Name string + DisplayName string + ShortName string + Enabled bool + RecordingEnabled bool + RecordingStorageID string +} + +func replaceMilestoneDeviceBaseName( + deviceName string, + oldHardwareName string, + newHardwareName string, +) (string, bool) { + deviceName = strings.TrimSpace(deviceName) + oldHardwareName = strings.TrimSpace(oldHardwareName) + newHardwareName = strings.TrimSpace(newHardwareName) + + if deviceName == "" || newHardwareName == "" { + return deviceName, false + } + + if oldHardwareName != "" { + if deviceName == oldHardwareName { + return newHardwareName, true + } + + if strings.HasPrefix(deviceName, oldHardwareName) { + suffix := strings.TrimPrefix(deviceName, oldHardwareName) + + if suffix == "" || + strings.HasPrefix(suffix, " ") || + strings.HasPrefix(suffix, "-") || + strings.HasPrefix(suffix, "_") { + return newHardwareName + suffix, true + } + } + } + + if baseName, suffix, found := strings.Cut(deviceName, " - "); found { + baseName = strings.TrimSpace(baseName) + suffix = strings.TrimSpace(suffix) + + if baseName != "" && suffix != "" && baseName != newHardwareName { + return newHardwareName + " - " + suffix, true + } + } + + return deviceName, false +} + +func milestoneItemEnabledValue(item map[string]any) bool { + enabled := true + + if value, ok := item["enabled"].(bool); ok { + enabled = value + } + + if value, ok := item["disabled"].(bool); ok { + enabled = !value + } + + return enabled +} + +func milestoneNamedDevicesFromHardware(hardware map[string]any) []milestoneNamedDevice { + devices := []milestoneNamedDevice{} + + devices = append(devices, milestoneNamedDevicesFromHardwareCollection(hardware, "cameras")...) + devices = append(devices, milestoneNamedDevicesFromHardwareCollection(hardware, "microphones")...) + + return devices +} + +func milestoneNamedDevicesFromHardwareCollectionOnly( + hardware map[string]any, + collection string, +) []milestoneNamedDevice { + return milestoneNamedDevicesFromHardwareCollection(hardware, collection) +} + +func milestoneNamedDevicesFromHardwareCollection( + hardware map[string]any, + collection string, +) []milestoneNamedDevice { + rawItems, ok := hardware[collection].([]any) + if !ok { + return nil + } + + devices := make([]milestoneNamedDevice, 0, len(rawItems)) + + for _, rawItem := range rawItems { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + + id := milestoneNamedDeviceID(item) + if id == "" { + continue + } + + name := milestoneStringValue(item["name"]) + displayName := milestoneStringValue(item["displayName"]) + shortName := milestoneStringValue(item["shortName"]) + + if name == "" { + name = displayName + } + if name == "" { + name = shortName + } + if displayName == "" { + displayName = name + } + if shortName == "" { + shortName = name + } + + if name == "" { + continue + } + + enabled := milestoneItemEnabledValue(item) + + recordingEnabled := false + if value, ok := item["recordingEnabled"].(bool); ok { + recordingEnabled = value + } + + recordingStorageID := "" + if recordingStorage, ok := item["recordingStorage"].(map[string]any); ok { + recordingStorageID = strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) + } + + devices = append(devices, milestoneNamedDevice{ + Collection: collection, + ID: id, + Name: name, + DisplayName: displayName, + ShortName: shortName, + Enabled: enabled, + RecordingEnabled: recordingEnabled, + RecordingStorageID: recordingStorageID, + }) + } + + return devices +} + +func mergeMilestoneNamedDevices(groups ...[]milestoneNamedDevice) []milestoneNamedDevice { + result := []milestoneNamedDevice{} + seen := map[string]bool{} + + for _, group := range groups { + for _, device := range group { + key := device.Collection + ":" + device.ID + if device.ID == "" || seen[key] { + continue + } + + seen[key] = true + result = append(result, device) + } + } + + return result +} + +func requestMilestoneNamedDevicesByHardware( + ctx context.Context, + config milestoneRuntimeConfig, + collection string, + hardwareID string, +) ([]milestoneNamedDevice, error) { + switch collection { + case "cameras", "microphones": + default: + return nil, fmt.Errorf("unsupported milestone device collection: %s", collection) + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/" + + collection + + "?disabled" + + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + requestURL, + nil, + ) + if err != nil { + return nil, err + } + + tokenType := strings.TrimSpace(config.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + request.Header.Set("Authorization", tokenType+" "+config.AccessToken) + request.Header.Set("Accept", "application/json") + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + responseBody, _ := io.ReadAll(response.Body) + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, newAppErrorf( + "milestone child device list failed", + LogFields{ + "statusCode": response.StatusCode, + "responseBody": string(responseBody), + "collection": collection, + "hardwareId": hardwareID, + }, + "milestone child device list failed: collection=%s hardwareId=%s status=%d body=%s", + collection, + hardwareID, + response.StatusCode, + string(responseBody), + ) + } + + return milestoneNamedDevicesFromListResponse(responseBody, collection, hardwareID) +} + +func milestoneNamedDevicesFromListResponse( + body []byte, + collection string, + hardwareID string, +) ([]milestoneNamedDevice, error) { + var decoded map[string]any + + if err := json.Unmarshal(body, &decoded); err != nil { + return nil, err + } + + var rawItems []any + + if array, ok := decoded["array"].([]any); ok { + rawItems = array + } else if array, ok := decoded["data"].([]any); ok { + rawItems = array + } else if data, ok := decoded["data"].(map[string]any); ok { + if array, ok := data["array"].([]any); ok { + rawItems = array + } + } + + if len(rawItems) == 0 { + return nil, nil + } + + devices := make([]milestoneNamedDevice, 0, len(rawItems)) + hardwareID = strings.TrimSpace(hardwareID) + + for _, rawItem := range rawItems { + item, ok := rawItem.(map[string]any) + if !ok { + continue + } + + parentID := milestoneNamedDeviceParentID(item) + if parentID != hardwareID { + continue + } + + id := milestoneNamedDeviceID(item) + if id == "" { + continue + } + + displayName := milestoneStringValue(item["displayName"]) + shortName := milestoneStringValue(item["shortName"]) + name := milestoneStringValue(item["name"]) + + if name == "" { + name = displayName + } + if name == "" { + name = shortName + } + if displayName == "" { + displayName = name + } + if shortName == "" { + shortName = name + } + if name == "" { + continue + } + + enabled := milestoneItemEnabledValue(item) + + recordingEnabled := false + if value, ok := item["recordingEnabled"].(bool); ok { + recordingEnabled = value + } + + recordingStorageID := "" + if recordingStorage, ok := item["recordingStorage"].(map[string]any); ok { + recordingStorageID = strings.TrimSpace(milestoneStringValue(recordingStorage["id"])) + } + + devices = append(devices, milestoneNamedDevice{ + Collection: collection, + ID: id, + Name: name, + DisplayName: displayName, + ShortName: shortName, + Enabled: enabled, + RecordingEnabled: recordingEnabled, + RecordingStorageID: recordingStorageID, + }) + } + + return devices, nil +} + +func milestoneNamedDeviceParentID(item map[string]any) string { + relations, ok := item["relations"].(map[string]any) + if !ok { + return "" + } + + parent, ok := relations["parent"].(map[string]any) + if !ok { + return "" + } + + return milestoneStringValue(parent["id"]) +} + +func milestoneNamedDeviceID(item map[string]any) string { + if id := milestoneStringValue(item["id"]); id != "" { + return id + } + + relations, ok := item["relations"].(map[string]any) + if !ok { + return "" + } + + self, ok := relations["self"].(map[string]any) + if !ok { + return "" + } + + return milestoneStringValue(self["id"]) +} + +func updateMilestoneChildDeviceBaseNames( + ctx context.Context, + config milestoneRuntimeConfig, + devices []milestoneNamedDevice, + oldHardwareName string, + newHardwareName string, +) error { + for _, device := range devices { + nextName, changed := replaceMilestoneDeviceBaseName( + device.Name, + oldHardwareName, + newHardwareName, + ) + if !changed { + continue + } + + if err := patchMilestoneNamedDevice( + ctx, + config, + device.Collection, + device.ID, + map[string]any{ + "name": nextName, + "displayName": nextName, + "shortName": nextName, + }, + ); err != nil { + return err + } + } + + return nil +} + +func patchMilestoneNamedDevice( + ctx context.Context, + config milestoneRuntimeConfig, + collection string, + deviceID string, + payload map[string]any, +) error { + switch collection { + case "cameras", "microphones": + default: + return fmt.Errorf("unsupported milestone device collection: %s", collection) + } + + body, err := json.Marshal(payload) + if err != nil { + return err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/" + + collection + + "/" + + url.PathEscape(deviceID) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPatch, + requestURL, + bytes.NewReader(body), + ) + if err != nil { + return err + } + + tokenType := strings.TrimSpace(config.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + request.Header.Set("Authorization", tokenType+" "+config.AccessToken) + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + + 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 newAppErrorf( + "milestone child device patch failed", + LogFields{ + "statusCode": response.StatusCode, + "responseBody": string(responseBody), + "collection": collection, + "deviceId": deviceID, + "payload": payload, + }, + "milestone child device patch failed: collection=%s id=%s status=%d body=%s", + collection, + deviceID, + response.StatusCode, + string(responseBody), + ) + } + + return nil +} diff --git a/backend/milestone_devices.go b/backend/milestone_devices.go deleted file mode 100644 index 3fb6c71..0000000 --- a/backend/milestone_devices.go +++ /dev/null @@ -1,490 +0,0 @@ -// backend\milestone_devices.go - -package main - -import ( - "context" - "strings" - "unicode" - - "github.com/jackc/pgx/v5" -) - -func syncMilestoneHardwareDevices( - ctx context.Context, - tx pgx.Tx, - hardwareDevices []milestoneHardwareDevice, -) (int, error) { - syncedCount := 0 - - for _, hardwareDevice := range hardwareDevices { - hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID) - hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID) - hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName) - hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName) - hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer) - hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName) - hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber) - hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress) - hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress) - hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port) - hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion) - - if hardwareDevice.HardwareID == "" { - continue - } - - if hardwareDevice.Manufacturer != "" { - if err := ensureOptionalDeviceLookupValue( - ctx, - tx, - "device_manufacturers", - hardwareDevice.Manufacturer, - ); err != nil { - return syncedCount, err - } - } - - synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice) - if err != nil { - return syncedCount, err - } - - if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil { - return syncedCount, err - } - - if synced { - syncedCount++ - } - } - - return syncedCount, nil -} - -func syncMilestoneHardwareChildDevices( - ctx context.Context, - tx pgx.Tx, - hardwareDevice milestoneHardwareDevice, -) error { - hardwareID := strings.TrimSpace(hardwareDevice.HardwareID) - if hardwareID == "" { - return nil - } - - _, err := tx.Exec( - ctx, - ` - DELETE FROM milestone_hardware_child_devices - WHERE milestone_hardware_id = $1 - `, - hardwareID, - ) - if err != nil { - return err - } - - for _, camera := range hardwareDevice.Cameras { - if err := upsertMilestoneHardwareChildDevice( - ctx, - tx, - hardwareID, - "camera", - camera, - ); err != nil { - return err - } - } - - for _, microphone := range hardwareDevice.Microphones { - if err := upsertMilestoneHardwareChildDevice( - ctx, - tx, - hardwareID, - "microphone", - microphone, - ); err != nil { - return err - } - } - - for _, input := range hardwareDevice.Inputs { - if err := upsertMilestoneHardwareChildDevice( - ctx, - tx, - hardwareID, - "input", - input, - ); err != nil { - return err - } - } - - for _, output := range hardwareDevice.Outputs { - if err := upsertMilestoneHardwareChildDevice( - ctx, - tx, - hardwareID, - "output", - output, - ); err != nil { - return err - } - } - - for _, speaker := range hardwareDevice.Speakers { - if err := upsertMilestoneHardwareChildDevice( - ctx, - tx, - hardwareID, - "speaker", - speaker, - ); err != nil { - return err - } - } - - for _, metadata := range hardwareDevice.Metadata { - if err := upsertMilestoneHardwareChildDevice( - ctx, - tx, - hardwareID, - "metadata", - metadata, - ); err != nil { - return err - } - } - - return nil -} - -func upsertMilestoneHardwareChildDevice( - ctx context.Context, - tx pgx.Tx, - hardwareID string, - deviceType string, - childDevice milestoneNamedDevice, -) error { - milestoneDeviceID := strings.TrimSpace(childDevice.ID) - if milestoneDeviceID == "" { - return nil - } - - name := strings.TrimSpace(childDevice.Name) - displayName := strings.TrimSpace(childDevice.DisplayName) - shortName := strings.TrimSpace(childDevice.ShortName) - - if name == "" { - name = displayName - } - if name == "" { - name = shortName - } - if displayName == "" { - displayName = name - } - if shortName == "" { - shortName = name - } - - _, err := tx.Exec( - ctx, - ` - INSERT INTO milestone_hardware_child_devices ( - milestone_hardware_id, - milestone_device_id, - device_type, - name, - display_name, - short_name, - enabled - ) - VALUES ($1, $2, $3, $4, $5, $6, $7) - ON CONFLICT (milestone_hardware_id, milestone_device_id, device_type) - DO UPDATE SET - name = EXCLUDED.name, - display_name = EXCLUDED.display_name, - short_name = EXCLUDED.short_name, - enabled = EXCLUDED.enabled, - updated_at = now() - `, - hardwareID, - milestoneDeviceID, - deviceType, - name, - displayName, - shortName, - childDevice.Enabled, - ) - - return err -} - -func createOrUpdateMilestoneHardwareDevice( - ctx context.Context, - tx pgx.Tx, - hardwareDevice milestoneHardwareDevice, -) (bool, error) { - inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice) - if err != nil { - return false, err - } - - commandTag, err := tx.Exec( - ctx, - ` - INSERT INTO devices ( - inventory_number, - manufacturer, - model, - serial_number, - mac_address, - ip_address, - milestone_port, - location, - loan_status, - is_bao_device, - comment, - firmware_version, - milestone_recording_server_id, - milestone_hardware_id, - milestone_display_name, - milestone_enabled, - milestone_synced_at - ) - VALUES ( - $1, - $2, - $3, - $4, - $5, - $6, - $7, - '', - 'Verfügbar', - false, - '', - $8, - $9, - $10, - $11, - $12, - now() - ) - ON CONFLICT (milestone_hardware_id) - WHERE milestone_hardware_id <> '' - DO UPDATE SET - inventory_number = EXCLUDED.inventory_number, - manufacturer = EXCLUDED.manufacturer, - model = EXCLUDED.model, - serial_number = EXCLUDED.serial_number, - mac_address = EXCLUDED.mac_address, - ip_address = EXCLUDED.ip_address, - milestone_port = EXCLUDED.milestone_port, - firmware_version = EXCLUDED.firmware_version, - milestone_recording_server_id = EXCLUDED.milestone_recording_server_id, - milestone_display_name = EXCLUDED.milestone_display_name, - milestone_enabled = EXCLUDED.milestone_enabled, - milestone_synced_at = now(), - updated_at = now() - `, - inventoryNumber, - hardwareDevice.Manufacturer, - hardwareDevice.DeviceModelName, - hardwareDevice.SerialNumber, - hardwareDevice.MacAddress, - hardwareDevice.IPAddress, - hardwareDevice.Port, - hardwareDevice.FirmwareVersion, - hardwareDevice.RecordingServerID, - hardwareDevice.HardwareID, - hardwareDevice.DisplayName, - hardwareDevice.Enabled, - ) - - if err != nil { - return false, err - } - - return commandTag.RowsAffected() > 0, nil -} - -func normalizeMilestoneManufacturer(value string, model string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - - model = strings.TrimSpace(model) - normalizedValue := strings.ToLower(value) - - if strings.EqualFold(value, model) || - strings.HasSuffix(normalizedValue, " device") || - strings.Contains(normalizedValue, " camera") || - strings.Contains(normalizedValue, " encoder") || - strings.Contains(normalizedValue, " video") { - if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" { - return manufacturer - } - } - - return value -} - -func extractMilestoneManufacturerFromName(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - - parts := strings.FieldsFunc(value, func(r rune) bool { - return unicode.IsSpace(r) || - r == '_' || - r == '-' || - r == '/' || - r == '\\' || - r == '(' || - r == ')' || - r == ',' - }) - - ignoredWords := map[string]bool{ - "device": true, - "camera": true, - "video": true, - "encoder": true, - "ip": true, - "network": true, - "hardware": true, - } - - for _, part := range parts { - part = strings.TrimSpace(part) - - if part == "" { - continue - } - - if isOnlyDigits(part) { - continue - } - - if ignoredWords[strings.ToLower(part)] { - continue - } - - return part - } - - return "" -} - -func buildMilestoneInventoryNumber( - ctx context.Context, - tx pgx.Tx, - hardwareDevice milestoneHardwareDevice, -) (string, error) { - inventoryNumber := extractInventoryNumberFromMilestoneDeviceName( - hardwareDevice.DeviceName, - ) - - if inventoryNumber == "" { - inventoryNumber = extractInventoryNumberFromMilestoneDeviceName( - hardwareDevice.DisplayName, - ) - } - - if inventoryNumber == "" { - inventoryNumber = buildGeneratedMilestoneInventoryNumber( - hardwareDevice.HardwareID, - ) - } - - var exists bool - - err := tx.QueryRow( - ctx, - ` - SELECT EXISTS( - SELECT 1 - FROM devices - WHERE lower(inventory_number) = lower($1) - AND milestone_hardware_id <> $2 - ) - `, - inventoryNumber, - hardwareDevice.HardwareID, - ).Scan(&exists) - if err != nil { - return "", err - } - - if !exists { - return inventoryNumber, nil - } - - suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", "")) - if len(suffix) > 8 { - suffix = suffix[:8] - } - - if suffix == "" { - suffix = "MS" - } - - return inventoryNumber + "-" + suffix, nil -} - -func extractInventoryNumberFromMilestoneDeviceName(value string) string { - value = strings.TrimSpace(value) - if value == "" { - return "" - } - - parts := strings.FieldsFunc(value, func(r rune) bool { - return r == '_' || r == '-' || unicode.IsSpace(r) - }) - - for _, part := range parts { - part = strings.TrimSpace(part) - - if part == "" { - continue - } - - if isOnlyDigits(part) { - return part - } - } - - return "" -} - -func isOnlyDigits(value string) bool { - if value == "" { - return false - } - - for _, r := range value { - if r < '0' || r > '9' { - return false - } - } - - return true -} - -func buildGeneratedMilestoneInventoryNumber(hardwareID string) string { - value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", "")) - - if len(value) > 12 { - value = value[:12] - } - - if value == "" { - value = "UNKNOWN" - } - - return "MS-" + value -} diff --git a/backend/milestone_inventory.go b/backend/milestone_inventory.go new file mode 100644 index 0000000..0bf61c4 --- /dev/null +++ b/backend/milestone_inventory.go @@ -0,0 +1,190 @@ +// backend\milestone_inventory.go + +package main + +import ( + "context" + "strings" + "unicode" + + "github.com/jackc/pgx/v5" +) + +func normalizeMilestoneManufacturer(value string, model string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + model = strings.TrimSpace(model) + normalizedValue := strings.ToLower(value) + + if strings.EqualFold(value, model) || + strings.HasSuffix(normalizedValue, " device") || + strings.Contains(normalizedValue, " camera") || + strings.Contains(normalizedValue, " encoder") || + strings.Contains(normalizedValue, " video") { + if manufacturer := extractMilestoneManufacturerFromName(value); manufacturer != "" { + return manufacturer + } + } + + return value +} + +func extractMilestoneManufacturerFromName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + parts := strings.FieldsFunc(value, func(r rune) bool { + return unicode.IsSpace(r) || + r == '_' || + r == '-' || + r == '/' || + r == '\\' || + r == '(' || + r == ')' || + r == ',' + }) + + ignoredWords := map[string]bool{ + "device": true, + "camera": true, + "video": true, + "encoder": true, + "ip": true, + "network": true, + "hardware": true, + } + + for _, part := range parts { + part = strings.TrimSpace(part) + + if part == "" { + continue + } + + if isOnlyDigits(part) { + continue + } + + if ignoredWords[strings.ToLower(part)] { + continue + } + + return part + } + + return "" +} + +func buildMilestoneInventoryNumber( + ctx context.Context, + tx pgx.Tx, + hardwareDevice milestoneHardwareDevice, +) (string, error) { + inventoryNumber := extractInventoryNumberFromMilestoneDeviceName( + hardwareDevice.DeviceName, + ) + + if inventoryNumber == "" { + inventoryNumber = extractInventoryNumberFromMilestoneDeviceName( + hardwareDevice.DisplayName, + ) + } + + if inventoryNumber == "" { + inventoryNumber = buildGeneratedMilestoneInventoryNumber( + hardwareDevice.HardwareID, + ) + } + + var exists bool + + err := tx.QueryRow( + ctx, + ` + SELECT EXISTS( + SELECT 1 + FROM devices + WHERE lower(inventory_number) = lower($1) + AND milestone_hardware_id <> $2 + ) + `, + inventoryNumber, + hardwareDevice.HardwareID, + ).Scan(&exists) + if err != nil { + return "", err + } + + if !exists { + return inventoryNumber, nil + } + + suffix := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareDevice.HardwareID), "-", "")) + if len(suffix) > 8 { + suffix = suffix[:8] + } + + if suffix == "" { + suffix = "MS" + } + + return inventoryNumber + "-" + suffix, nil +} + +func extractInventoryNumberFromMilestoneDeviceName(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + + parts := strings.FieldsFunc(value, func(r rune) bool { + return r == '_' || r == '-' || unicode.IsSpace(r) + }) + + for _, part := range parts { + part = strings.TrimSpace(part) + + if part == "" { + continue + } + + if isOnlyDigits(part) { + return part + } + } + + return "" +} + +func isOnlyDigits(value string) bool { + if value == "" { + return false + } + + for _, r := range value { + if r < '0' || r > '9' { + return false + } + } + + return true +} + +func buildGeneratedMilestoneInventoryNumber(hardwareID string) string { + value := strings.ToUpper(strings.ReplaceAll(strings.TrimSpace(hardwareID), "-", "")) + + if len(value) > 12 { + value = value[:12] + } + + if value == "" { + value = "UNKNOWN" + } + + return "MS-" + value +} diff --git a/backend/milestone_storage.go b/backend/milestone_storage.go new file mode 100644 index 0000000..ac1c8c2 --- /dev/null +++ b/backend/milestone_storage.go @@ -0,0 +1,1119 @@ +// backend\milestone_storage.go + +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" +) + +type milestoneArchiveStorage struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DiskPath string `json:"diskPath"` + MaxSize int `json:"maxSize,omitempty"` + RetainMinutes int `json:"retainMinutes,omitempty"` + Mounted bool `json:"mounted"` +} + +type milestoneArchiveStoragesResponse struct { + Array []milestoneArchiveStorage `json:"array"` +} + +type milestoneStorage struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DiskPath string `json:"diskPath"` + MaxSize int `json:"maxSize,omitempty"` + RetainMinutes int `json:"retainMinutes,omitempty"` + Mounted bool `json:"mounted"` +} + +type milestoneStorageResponse struct { + Data milestoneStorage `json:"data"` +} + +type milestoneStorageDetails struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DiskPath string `json:"diskPath"` + MaxSize int `json:"maxSize,omitempty"` + RetainMinutes int `json:"retainMinutes,omitempty"` + Mounted bool `json:"mounted"` + ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"` +} + +type milestoneStorageDetailsResponse struct { + Data milestoneStorageDetails `json:"data"` +} + +type milestoneStoragesResponse struct { + Array []milestoneStorage `json:"array"` +} + +type milestoneStorageWithArchives struct { + ID string `json:"id"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description,omitempty"` + DiskPath string `json:"diskPath"` + MaxSize int `json:"maxSize,omitempty"` + RetainMinutes int `json:"retainMinutes,omitempty"` + Mounted bool `json:"mounted"` + StorageInformation *milestoneStorageInformation `json:"storageInformation,omitempty"` + ArchiveStorages []milestoneArchiveStorage `json:"archiveStorages"` +} + +type createMilestoneArchiveStorageRequest struct { + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description"` + DiskPath string `json:"diskPath"` + MaxSize int `json:"maxSize"` + RetainMinutes int `json:"retainMinutes"` +} + +type updateMilestoneArchiveStorageRequest struct { + DisplayName *string `json:"displayName"` + Name *string `json:"name"` + Description *string `json:"description"` + DiskPath *string `json:"diskPath"` + MaxSize *int `json:"maxSize"` + RetainMinutes *int `json:"retainMinutes"` +} + +type createMilestoneAutoArchiveStorageRequest struct { + ArchiveFolderName string `json:"archiveFolderName"` + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description"` + MaxSize int `json:"maxSize"` + RetainMinutes int `json:"retainMinutes"` +} + +type createMilestoneStorageWithArchiveRequest struct { + DisplayName string `json:"displayName"` + Name string `json:"name"` + Description string `json:"description"` + DiskPath string `json:"diskPath"` + MaxSize int `json:"maxSize"` + RetainMinutes int `json:"retainMinutes"` + Signing bool `json:"signing"` + + ArchiveDisplayName string `json:"archiveDisplayName"` + ArchiveName string `json:"archiveName"` + ArchiveDescription string `json:"archiveDescription"` + ArchiveDiskPath string `json:"archiveDiskPath"` + ArchiveFolderName string `json:"archiveFolderName"` + ArchiveMaxSize int `json:"archiveMaxSize"` + ArchiveRetainMinutes int `json:"archiveRetainMinutes"` +} + +type milestoneStorageInformationRelationRef struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type milestoneStorageInformationRelations struct { + Self milestoneStorageInformationRelationRef `json:"self"` + Parent milestoneStorageInformationRelationRef `json:"parent"` +} + +type milestoneStorageInformation struct { + AnyTablesWithData string `json:"anyTablesWithData"` + DisplayName string `json:"displayName"` + IsAvailable bool `json:"isAvailable"` + IsMounted bool `json:"isMounted"` + LastUpdated string `json:"lastUpdated"` + LockedUsedSpace int64 `json:"lockedUsedSpace"` + UsedSpace int64 `json:"usedSpace"` + Relations milestoneStorageInformationRelations `json:"relations"` +} + +type milestoneStorageInformationTask struct { + Name string `json:"name"` + DisplayName string `json:"displayName"` +} + +type milestoneStorageInformationResource struct { + Type string `json:"type"` + DisplayName string `json:"displayName"` +} + +type milestoneStorageInformationResponse struct { + Data milestoneStorageInformation `json:"data"` + Tasks []milestoneStorageInformationTask `json:"tasks"` + Resources []milestoneStorageInformationResource `json:"resources"` +} + +func getMilestoneStorageInformation( + ctx context.Context, + config milestoneRuntimeConfig, + storageInformationID string, +) (*milestoneStorageInformationResponse, error) { + storageInformationID = strings.TrimSpace(storageInformationID) + if storageInformationID == "" { + return nil, nil + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/storageInformation/" + url.PathEscape(storageInformationID) + + 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 storage information get failed: status=%d body=%s", + response.StatusCode, + string(body), + ) + } + + var storageInformation milestoneStorageInformationResponse + if err := json.Unmarshal(body, &storageInformation); err != nil { + return nil, err + } + + return &storageInformation, nil +} + +func (s *Server) handleGetMilestoneStorageInformation(w http.ResponseWriter, r *http.Request) { + storageInformationID := strings.TrimSpace(r.PathValue("id")) + if storageInformationID == "" { + writeError(w, http.StatusBadRequest, "Storage-Information-ID fehlt") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + storageInformation, err := getMilestoneStorageInformation( + r.Context(), + config, + storageInformationID, + ) + if err != nil { + log.Printf("Milestone storage information load failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Storage-Information konnte nicht geladen werden") + return + } + + if storageInformation == nil { + writeError(w, http.StatusNotFound, "Milestone-Storage-Information wurde nicht gefunden") + return + } + + writeJSON(w, http.StatusOK, storageInformation) +} + +func getMilestoneArchiveStorages( + ctx context.Context, + config milestoneRuntimeConfig, + storageID string, +) ([]milestoneArchiveStorage, error) { + storageID = strings.TrimSpace(storageID) + if storageID == "" { + return []milestoneArchiveStorage{}, nil + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/storages/" + url.PathEscape(storageID) + "/archiveStorages" + + 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 archive storages get failed: status=%d body=%s", + response.StatusCode, + string(body), + ) + } + + archives, err := decodeMilestoneArchiveStorages(body) + if err != nil { + return nil, err + } + + if archives == nil { + return []milestoneArchiveStorage{}, nil + } + + return archives, nil +} + +func decodeMilestoneArchiveStorages(body []byte) ([]milestoneArchiveStorage, error) { + var arrayResponse milestoneArchiveStoragesResponse + if err := json.Unmarshal(body, &arrayResponse); err == nil && arrayResponse.Array != nil { + return arrayResponse.Array, nil + } + + var directArray []milestoneArchiveStorage + if err := json.Unmarshal(body, &directArray); err == nil && directArray != nil { + return directArray, nil + } + + var detailsResponse milestoneStorageDetailsResponse + if err := json.Unmarshal(body, &detailsResponse); err == nil && detailsResponse.Data.ArchiveStorages != nil { + return detailsResponse.Data.ArchiveStorages, nil + } + + var generic map[string]json.RawMessage + if err := json.Unmarshal(body, &generic); err != nil { + return nil, err + } + + if rawArray, ok := generic["archiveStorages"]; ok { + var archives []milestoneArchiveStorage + if err := json.Unmarshal(rawArray, &archives); err != nil { + return nil, err + } + return archives, nil + } + + if rawData, ok := generic["data"]; ok { + var data map[string]json.RawMessage + if err := json.Unmarshal(rawData, &data); err != nil { + return nil, err + } + if rawArray, ok := data["archiveStorages"]; ok { + var archives []milestoneArchiveStorage + if err := json.Unmarshal(rawArray, &archives); err != nil { + return nil, err + } + return archives, nil + } + } + + return []milestoneArchiveStorage{}, nil +} + +func getMilestoneStorages( + ctx context.Context, + config milestoneRuntimeConfig, +) ([]milestoneStorage, error) { + recordingServerID := strings.TrimSpace(config.ServerID) + if recordingServerID == "" { + return nil, fmt.Errorf("milestone recording server id is missing") + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/recordingServers/" + + url.PathEscape(recordingServerID) + + "/storages" + + 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 storages get failed: status=%d body=%s", + response.StatusCode, + string(body), + ) + } + + var storagesResponse milestoneStoragesResponse + if err := json.Unmarshal(body, &storagesResponse); err == nil && storagesResponse.Array != nil { + return storagesResponse.Array, nil + } + + var directArray []milestoneStorage + if err := json.Unmarshal(body, &directArray); err == nil && directArray != nil { + return directArray, nil + } + + return []milestoneStorage{}, nil +} + +func getMilestoneStoragesWithArchives( + ctx context.Context, + config milestoneRuntimeConfig, +) ([]milestoneStorageWithArchives, error) { + storages, err := getMilestoneStorages(ctx, config) + if err != nil { + return nil, err + } + + result := make([]milestoneStorageWithArchives, 0, len(storages)) + + for _, storage := range storages { + archives, err := getMilestoneArchiveStorages(ctx, config, storage.ID) + if err != nil { + return nil, err + } + + if archives == nil { + archives = []milestoneArchiveStorage{} + } + + var storageInformation *milestoneStorageInformation + + storageInformationResponse, err := getMilestoneStorageInformation( + ctx, + config, + storage.ID, + ) + if err != nil { + log.Printf( + "Milestone storage information could not be loaded: storageId=%s error=%v", + storage.ID, + err, + ) + } else if storageInformationResponse != nil { + storageInformation = &storageInformationResponse.Data + } + + result = append(result, milestoneStorageWithArchives{ + ID: storage.ID, + DisplayName: storage.DisplayName, + Name: storage.Name, + Description: storage.Description, + DiskPath: storage.DiskPath, + MaxSize: storage.MaxSize, + RetainMinutes: storage.RetainMinutes, + Mounted: storage.Mounted, + StorageInformation: storageInformation, + ArchiveStorages: archives, + }) + } + + return result, nil +} + +func (s *Server) handleListMilestoneStorages(w http.ResponseWriter, r *http.Request) { + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + log.Printf("Milestone storages load failed: runtime config error: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + storages, err := getMilestoneStoragesWithArchives(r.Context(), config) + if err != nil { + log.Printf("Milestone storages load failed: %v", err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher konnten nicht geladen werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "storages": storages, + }) +} + +func (s *Server) handleCreateMilestoneStorageWithArchive(w http.ResponseWriter, r *http.Request) { + var input createMilestoneStorageWithArchiveRequest + if err := readJSON(r, &input); err != nil { + writeError(w, http.StatusBadRequest, "Invalid JSON") + return + } + + displayName := strings.TrimSpace(input.DisplayName) + name := strings.TrimSpace(input.Name) + diskPath := strings.TrimSpace(input.DiskPath) + + if displayName == "" { + writeError(w, http.StatusBadRequest, "Anzeigename fehlt") + return + } + if name == "" { + writeError(w, http.StatusBadRequest, "Name fehlt") + return + } + if diskPath == "" { + writeError(w, http.StatusBadRequest, "Speicherpfad fehlt") + return + } + + config, err := s.getMilestoneRuntimeConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") + return + } + + storagePayload := map[string]any{ + "displayName": displayName, + "name": name, + "description": strings.TrimSpace(input.Description), + "diskPath": diskPath, + "maxSize": input.MaxSize, + "signing": input.Signing, + } + + if input.RetainMinutes > 0 { + storagePayload["retainMinutes"] = input.RetainMinutes + } + + storage, err := postMilestoneStorage(r.Context(), config, storagePayload) + if err != nil { + log.Printf("Milestone storage create failed: error=%v", err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher konnte nicht angelegt werden") + return + } + + if storage == nil || strings.TrimSpace(storage.ID) == "" { + storage, err = findMilestoneStorageAfterCreate(r.Context(), config, input) + if err != nil { + log.Printf("Milestone storage lookup after create failed: error=%v", err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher wurde angelegt, konnte aber nicht wiedergefunden werden") + return + } + } + + if storage == nil || strings.TrimSpace(storage.ID) == "" { + writeError(w, http.StatusBadGateway, "Milestone hat keine Storage-ID zurückgegeben") + return + } + + archiveFolderName := strings.TrimSpace(input.ArchiveFolderName) + if archiveFolderName == "" { + archiveFolderName = "Archive" + } + + archiveDiskPath := strings.TrimSpace(input.ArchiveDiskPath) + if archiveDiskPath == "" { + archiveDiskPath = joinMilestoneDiskPath(storage.DiskPath, archiveFolderName) + } + if archiveDiskPath == "" { + writeError(w, http.StatusBadRequest, "Archivpfad fehlt") + return + } + + archiveDisplayName := strings.TrimSpace(input.ArchiveDisplayName) + if archiveDisplayName == "" { + archiveDisplayName = formatArchiveDisplayName(storage.DisplayName, storage.Name, storage.ID) + } + + archiveName := strings.TrimSpace(input.ArchiveName) + if archiveName == "" { + archiveName = formatArchiveName(storage.Name, storage.ID) + } + + archivePayload := map[string]any{ + "displayName": archiveDisplayName, + "name": archiveName, + "description": strings.TrimSpace(input.ArchiveDescription), + "diskPath": archiveDiskPath, + "maxSize": input.ArchiveMaxSize, + } + + if input.ArchiveRetainMinutes > 0 { + archivePayload["retainMinutes"] = input.ArchiveRetainMinutes + } + + archiveStorage, err := postMilestoneArchiveStorage(r.Context(), config, storage.ID, archivePayload) + if err != nil { + log.Printf("Milestone archive storage create after storage create failed: storageId=%s error=%v", storage.ID, err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher wurde angelegt, aber Archivspeicher konnte nicht angelegt werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "storage": storage, + "archiveStorage": archiveStorage, + }) +} + +func (s *Server) handleCreateMilestoneArchiveStorage(w http.ResponseWriter, r *http.Request) { + storageID := strings.TrimSpace(r.PathValue("id")) + if storageID == "" { + writeError(w, http.StatusBadRequest, "Storage-ID fehlt") + return + } + + var input createMilestoneArchiveStorageRequest + if err := readJSON(r, &input); 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 + } + + payload := map[string]any{ + "displayName": strings.TrimSpace(input.DisplayName), + "name": strings.TrimSpace(input.Name), + "description": strings.TrimSpace(input.Description), + "diskPath": strings.TrimSpace(input.DiskPath), + "maxSize": input.MaxSize, + } + + if input.RetainMinutes > 0 { + payload["retainMinutes"] = input.RetainMinutes + } + + archiveStorage, err := postMilestoneArchiveStorage(r.Context(), config, storageID, payload) + if err != nil { + log.Printf("Milestone archive storage create failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Archive-Storage konnte nicht angelegt werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "archiveStorage": archiveStorage, + }) +} + +func (s *Server) handleCreateMilestoneAutoArchiveStorage(w http.ResponseWriter, r *http.Request) { + storageID := strings.TrimSpace(r.PathValue("id")) + if storageID == "" { + writeError(w, http.StatusBadRequest, "Storage-ID fehlt") + return + } + + var input createMilestoneAutoArchiveStorageRequest + if err := readJSON(r, &input); 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 + } + + storage, err := getMilestoneStorage(r.Context(), config, storageID) + if err != nil { + log.Printf("Milestone storage load failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Milestone-Speicher konnte nicht geladen werden") + return + } + + if storage == nil { + writeError(w, http.StatusNotFound, "Milestone-Speicher wurde nicht gefunden") + return + } + + archiveFolderName := strings.TrimSpace(input.ArchiveFolderName) + if archiveFolderName == "" { + archiveFolderName = "Archive" + } + + archiveDiskPath := joinMilestoneDiskPath(storage.DiskPath, archiveFolderName) + if archiveDiskPath == "" { + writeError(w, http.StatusBadRequest, "Archivpfad konnte nicht aus dem Speicherpfad gebildet werden") + return + } + + displayName := strings.TrimSpace(input.DisplayName) + if displayName == "" { + displayName = formatArchiveDisplayName(storage.DisplayName, storage.Name, storage.ID) + } + + name := strings.TrimSpace(input.Name) + if name == "" { + name = formatArchiveName(storage.Name, storage.ID) + } + + payload := map[string]any{ + "displayName": displayName, + "name": name, + "description": strings.TrimSpace(input.Description), + "diskPath": archiveDiskPath, + "maxSize": input.MaxSize, + } + + if input.RetainMinutes > 0 { + payload["retainMinutes"] = input.RetainMinutes + } + + archiveStorage, err := postMilestoneArchiveStorage(r.Context(), config, storageID, payload) + if err != nil { + log.Printf("Milestone auto archive storage create failed: storageId=%s error=%v", storageID, err) + writeError(w, http.StatusBadGateway, "Automatischer Archivspeicher konnte nicht angelegt werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + "diskPath": archiveDiskPath, + "archiveStorage": archiveStorage, + }) +} + +func joinMilestoneDiskPath(basePath string, childPath string) string { + basePath = strings.TrimSpace(basePath) + childPath = strings.TrimSpace(childPath) + + basePath = strings.TrimRight(basePath, `\/`) + childPath = strings.TrimLeft(childPath, `\/`) + + if basePath == "" { + return childPath + } + + if childPath == "" { + return basePath + } + + separator := `\` + if strings.Contains(basePath, "/") && !strings.Contains(basePath, `\`) { + separator = "/" + } + + return basePath + separator + childPath +} + +func formatArchiveDisplayName(displayName string, name string, id string) string { + base := strings.TrimSpace(displayName) + if base == "" { + base = strings.TrimSpace(name) + } + if base == "" { + base = strings.TrimSpace(id) + } + if base == "" { + base = "Storage" + } + + return base + " Archiv" +} + +func formatArchiveName(name string, id string) string { + base := strings.TrimSpace(name) + if base == "" { + base = strings.TrimSpace(id) + } + if base == "" { + base = "storage" + } + + base = strings.ReplaceAll(base, " ", "_") + base = strings.ReplaceAll(base, `\`, "_") + base = strings.ReplaceAll(base, "/", "_") + + return base + "_archive" +} + +func postMilestoneStorage( + ctx context.Context, + config milestoneRuntimeConfig, + payload map[string]any, +) (*milestoneStorage, error) { + recordingServerID := strings.TrimSpace(config.ServerID) + if recordingServerID == "" { + return nil, fmt.Errorf("milestone recording server id is missing") + } + + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/recordingServers/" + + url.PathEscape(recordingServerID) + + "/storages" + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + requestURL, + strings.NewReader(string(body)), + ) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, true) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf( + "milestone storage post failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + storage, _ := decodeMilestoneStorage(responseBody) + return storage, nil +} + +func findMilestoneStorageAfterCreate( + ctx context.Context, + config milestoneRuntimeConfig, + input createMilestoneStorageWithArchiveRequest, +) (*milestoneStorage, error) { + storages, err := getMilestoneStorages(ctx, config) + if err != nil { + return nil, err + } + + name := strings.TrimSpace(input.Name) + displayName := strings.TrimSpace(input.DisplayName) + diskPath := strings.TrimSpace(input.DiskPath) + + for _, storage := range storages { + if name != "" && strings.EqualFold(strings.TrimSpace(storage.Name), name) { + matched := storage + return &matched, nil + } + } + + for _, storage := range storages { + if diskPath != "" && strings.EqualFold(strings.TrimSpace(storage.DiskPath), diskPath) { + matched := storage + return &matched, nil + } + } + + for _, storage := range storages { + if displayName != "" && strings.EqualFold(strings.TrimSpace(storage.DisplayName), displayName) { + matched := storage + return &matched, nil + } + } + + return nil, nil +} + +func postMilestoneArchiveStorage( + ctx context.Context, + config milestoneRuntimeConfig, + storageID string, + payload map[string]any, +) (*milestoneArchiveStorage, error) { + body, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/storages/" + url.PathEscape(storageID) + "/archiveStorages" + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPost, + requestURL, + strings.NewReader(string(body)), + ) + if err != nil { + return nil, err + } + + setMilestoneRequestHeaders(request, config, true) + + response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + + responseBody, err := io.ReadAll(response.Body) + if err != nil { + return nil, err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf( + "milestone archive storage post failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + archiveStorage, _ := decodeMilestoneArchiveStorage(responseBody) + return archiveStorage, nil +} + +func patchMilestoneArchiveStorage( + ctx context.Context, + config milestoneRuntimeConfig, + archiveStorageID string, + payload map[string]any, +) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/archiveStorages/" + url.PathEscape(archiveStorageID) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodPatch, + requestURL, + strings.NewReader(string(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, err := io.ReadAll(response.Body) + if err != nil { + return err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return fmt.Errorf( + "milestone archive storage patch failed: status=%d body=%s", + response.StatusCode, + string(responseBody), + ) + } + + return nil +} + +func (s *Server) handleUpdateMilestoneArchiveStorage(w http.ResponseWriter, r *http.Request) { + archiveStorageID := strings.TrimSpace(r.PathValue("id")) + if archiveStorageID == "" { + writeError(w, http.StatusBadRequest, "Archive-Storage-ID fehlt") + return + } + + var input updateMilestoneArchiveStorageRequest + if err := readJSON(r, &input); 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 + } + + payload := map[string]any{} + + if input.DisplayName != nil { + payload["displayName"] = strings.TrimSpace(*input.DisplayName) + } + if input.Name != nil { + payload["name"] = strings.TrimSpace(*input.Name) + } + if input.Description != nil { + payload["description"] = strings.TrimSpace(*input.Description) + } + if input.DiskPath != nil { + payload["diskPath"] = strings.TrimSpace(*input.DiskPath) + } + if input.MaxSize != nil { + payload["maxSize"] = *input.MaxSize + } + if input.RetainMinutes != nil { + payload["retainMinutes"] = *input.RetainMinutes + } + + if len(payload) == 0 { + writeJSON(w, http.StatusOK, map[string]any{"ok": true}) + return + } + + if err := patchMilestoneArchiveStorage(r.Context(), config, archiveStorageID, payload); err != nil { + log.Printf("Milestone archive storage update failed: archiveStorageId=%s error=%v", archiveStorageID, err) + writeError(w, http.StatusBadGateway, "Archive-Storage konnte nicht aktualisiert werden") + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "ok": true, + }) +} + +func getMilestoneStorage( + ctx context.Context, + config milestoneRuntimeConfig, + storageID string, +) (*milestoneStorage, error) { + storageID = strings.TrimSpace(storageID) + if storageID == "" { + return nil, nil + } + + requestURL := strings.TrimRight(config.Host, "/") + + "/API/rest/v1/storages/" + url.PathEscape(storageID) + + 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 storage get failed: status=%d body=%s", + response.StatusCode, + string(body), + ) + } + + var storageResponse milestoneStorageResponse + if err := json.Unmarshal(body, &storageResponse); err == nil && strings.TrimSpace(storageResponse.Data.ID) != "" { + return &storageResponse.Data, nil + } + + var direct milestoneStorage + if err := json.Unmarshal(body, &direct); err != nil { + return nil, err + } + + if strings.TrimSpace(direct.ID) == "" { + return nil, nil + } + + return &direct, nil +} + +func decodeMilestoneStorage(body []byte) (*milestoneStorage, error) { + if len(strings.TrimSpace(string(body))) == 0 { + return nil, nil + } + + var envelope milestoneStorageResponse + if err := json.Unmarshal(body, &envelope); err == nil && strings.TrimSpace(envelope.Data.ID) != "" { + return &envelope.Data, nil + } + + var direct milestoneStorage + if err := json.Unmarshal(body, &direct); err == nil && strings.TrimSpace(direct.ID) != "" { + return &direct, nil + } + + var generic map[string]json.RawMessage + if err := json.Unmarshal(body, &generic); err != nil { + return nil, err + } + + if rawData, ok := generic["data"]; ok { + var storage milestoneStorage + if err := json.Unmarshal(rawData, &storage); err == nil && strings.TrimSpace(storage.ID) != "" { + return &storage, nil + } + } + + return nil, nil +} + +func decodeMilestoneArchiveStorage(body []byte) (*milestoneArchiveStorage, error) { + if len(strings.TrimSpace(string(body))) == 0 { + return nil, nil + } + + var envelope struct { + Data milestoneArchiveStorage `json:"data"` + } + if err := json.Unmarshal(body, &envelope); err == nil && strings.TrimSpace(envelope.Data.ID) != "" { + return &envelope.Data, nil + } + + var direct milestoneArchiveStorage + if err := json.Unmarshal(body, &direct); err == nil && strings.TrimSpace(direct.ID) != "" { + return &direct, nil + } + + return nil, nil +} + +func setMilestoneRequestHeaders(request *http.Request, config milestoneRuntimeConfig, hasBody bool) { + tokenType := strings.TrimSpace(config.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + request.Header.Set("Authorization", tokenType+" "+config.AccessToken) + request.Header.Set("Accept", "application/json") + + if hasBody { + request.Header.Set("Content-Type", "application/json") + } +} diff --git a/backend/milestone_sync.go b/backend/milestone_sync.go new file mode 100644 index 0000000..2d02d32 --- /dev/null +++ b/backend/milestone_sync.go @@ -0,0 +1,1107 @@ +// backend\milestone_sync.go + +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type milestoneSyncResult struct { + TokenReceived bool `json:"tokenReceived"` + TokenType string `json:"tokenType"` + TokenExpiresAt time.Time `json:"tokenExpiresAt"` + RecordingServerID string `json:"recordingServerId"` + RecordingServerName string `json:"recordingServerName"` + RecordingServerVersion string `json:"recordingServerVersion"` + HardwareCount int `json:"hardwareCount"` +} + +type milestoneSyncEvent struct { + Type string `json:"type"` + Message string `json:"message,omitempty"` + TokenType string `json:"tokenType,omitempty"` + TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` + RecordingServerID string `json:"recordingServerId,omitempty"` + RecordingServerName string `json:"recordingServerName,omitempty"` + RecordingServerVersion string `json:"recordingServerVersion,omitempty"` + HardwareCount int `json:"hardwareCount,omitempty"` + Settings *milestoneSettingsResponse `json:"settings,omitempty"` +} + +type milestoneRecordingServersResponse struct { + Array []milestoneRecordingServer `json:"array"` +} + +type milestoneRecordingServer struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + Version string `json:"version"` + ProductVersion string `json:"productVersion"` + RecordingServerVersion string `json:"recordingServerVersion"` +} + +type milestoneHardwareResponse struct { + Array []milestoneHardware `json:"array"` +} + +type milestoneHardware struct { + ID string `json:"id"` + Name string `json:"name"` + DisplayName string `json:"displayName"` + DeviceName string `json:"deviceName"` + Address string `json:"address"` + Enabled *bool `json:"enabled"` + Disabled *bool `json:"disabled"` + Relations milestoneRelations `json:"relations"` +} + +type milestoneRelations struct { + Parent milestoneRelationRef `json:"parent"` + Self milestoneRelationRef `json:"self"` +} + +type milestoneRelationRef struct { + Type string `json:"type"` + ID string `json:"id"` +} + +type milestoneHardwareDriverSettingsResponse struct { + Array []milestoneHardwareDriverSettingsItem `json:"array"` +} + +type milestoneHardwareDriverSettingsItem struct { + DisplayName string `json:"displayName"` + HardwareDriverSettings milestoneHardwareDriverSettings `json:"hardwareDriverSettings"` + Relations milestoneRelations `json:"relations"` +} + +type milestoneHardwareDriverSettings struct { + DetectedModelName string `json:"detectedModelName"` + ProductID string `json:"productID"` + MacAddress string `json:"macAddress"` + IPAddress string `json:"ipAddress"` + Address string `json:"address"` + HostName string `json:"hostName"` + DeviceModelName string `json:"deviceModelName"` + DeviceManufacturer string `json:"deviceManufacturer"` + FirmwareVersion string `json:"firmwareVersion"` + SerialNumber string `json:"serialNumber"` +} + +type milestoneHardwareDevice struct { + HardwareID string + RecordingServerID string + DisplayName string + DeviceName string + Manufacturer string + MacAddress string + IPAddress string + Port string + DeviceModelName string + SerialNumber string + FirmwareVersion string + Enabled bool + Cameras []milestoneNamedDevice + Microphones []milestoneNamedDevice +} + +func writeMilestoneSyncEvent(w http.ResponseWriter, event milestoneSyncEvent) { + if err := json.NewEncoder(w).Encode(event); err != nil { + log.Printf("Milestone sync stream event could not be written: %v", err) + return + } + + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } +} + +func emitMilestoneSyncEvent( + progress func(milestoneSyncEvent), + event milestoneSyncEvent, +) { + if progress != nil { + progress(event) + } +} + +func (s *Server) refreshMilestoneTokenAndServerID( + ctx context.Context, + credentials milestoneCredentials, +) (milestoneSyncResult, error) { + return s.refreshMilestoneTokenAndServerIDWithProgress(ctx, credentials, nil) +} + +func (s *Server) refreshMilestoneTokenAndServerIDWithProgress( + ctx context.Context, + credentials milestoneCredentials, + progress func(milestoneSyncEvent), +) (milestoneSyncResult, error) { + var syncResult milestoneSyncResult + + tokenResponse, err := s.requestMilestoneToken(ctx, credentials) + if err != nil { + return syncResult, err + } + + accessToken := strings.TrimSpace(tokenResponse.AccessToken) + if accessToken == "" { + return syncResult, errors.New("milestone returned empty access token") + } + + tokenType := strings.TrimSpace(tokenResponse.TokenType) + if tokenType == "" { + tokenType = "Bearer" + } + + expiresIn := tokenResponse.ExpiresIn + if expiresIn <= 0 { + expiresIn = 3600 + } + + tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) + + syncResult.TokenReceived = true + syncResult.TokenType = tokenType + syncResult.TokenExpiresAt = tokenExpiresAt + + emitMilestoneSyncEvent(progress, milestoneSyncEvent{ + Type: "token", + TokenType: tokenType, + TokenExpiresAt: &tokenExpiresAt, + }) + + serverID, serverName, serverVersion, err := s.requestMilestoneServerID( + ctx, + credentials.Host, + tokenType, + accessToken, + credentials.SkipTLSVerify, + ) + if err != nil { + return syncResult, err + } + + syncResult.RecordingServerID = serverID + syncResult.RecordingServerName = serverName + syncResult.RecordingServerVersion = serverVersion + + emitMilestoneSyncEvent(progress, milestoneSyncEvent{ + Type: "recordingServer", + RecordingServerID: serverID, + RecordingServerName: serverName, + RecordingServerVersion: serverVersion, + }) + + hardwareDevices, err := s.requestMilestoneHardwareDevices( + ctx, + credentials.Host, + serverID, + accessToken, + credentials.SkipTLSVerify, + ) + if err != nil { + return syncResult, err + } + + tx, err := s.db.Begin(ctx) + if err != nil { + return syncResult, err + } + defer tx.Rollback(ctx) + + encryptionKey := s.milestoneEncryptionKey() + + _, err = tx.Exec( + ctx, + ` + UPDATE milestone_settings + SET + access_token_encrypted = pgp_sym_encrypt($1, $2), + token_type = $3, + token_expires_at = $4, + token_updated_at = now(), + server_id = $5, + server_name = $6, + server_version = $7, + server_updated_at = now(), + updated_at = now() + WHERE id = 1 + `, + accessToken, + encryptionKey, + tokenType, + tokenExpiresAt, + serverID, + serverName, + serverVersion, + ) + if err != nil { + return syncResult, err + } + + syncedHardwareCount, err := syncMilestoneHardwareDevices(ctx, tx, hardwareDevices) + if err != nil { + return syncResult, err + } + + syncResult.HardwareCount = syncedHardwareCount + + if err := tx.Commit(ctx); err != nil { + return syncResult, err + } + + emitMilestoneSyncEvent(progress, milestoneSyncEvent{ + Type: "hardware", + HardwareCount: syncResult.HardwareCount, + }) + + log.Printf( + "Milestone sync completed successfully: recordingServerId=%s hardwareDevices=%d", + serverID, + syncedHardwareCount, + ) + + return syncResult, nil +} + +func (s *Server) requestMilestoneServerID( + ctx context.Context, + host string, + tokenType string, + accessToken string, + skipTLSVerify bool, +) (string, string, string, error) { + requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers" + + log.Printf( + "Requesting Milestone recording server id: url=%s skipTLSVerify=%t", + requestURL, + skipTLSVerify, + ) + + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + requestURL, + nil, + ) + if err != nil { + return "", "", "", err + } + + if tokenType == "" { + tokenType = "Bearer" + } + + request.Header.Set("Authorization", tokenType+" "+accessToken) + request.Header.Set("Accept", "application/json") + + client := milestoneHTTPClient(skipTLSVerify) + + response, err := client.Do(request) + if err != nil { + return "", "", "", err + } + defer response.Body.Close() + + body, err := io.ReadAll(response.Body) + if err != nil { + return "", "", "", err + } + + if response.StatusCode < 200 || response.StatusCode >= 300 { + return "", "", "", fmt.Errorf( + "milestone recording servers request failed: url=%s status=%d body=%s", + requestURL, + response.StatusCode, + truncateMilestoneLogBody(body), + ) + } + + var recordingServersResponse milestoneRecordingServersResponse + if err := json.Unmarshal(body, &recordingServersResponse); err != nil { + return "", "", "", fmt.Errorf( + "milestone recording servers response could not be parsed: body=%s error=%w", + truncateMilestoneLogBody(body), + err, + ) + } + + if len(recordingServersResponse.Array) == 0 { + return "", "", "", fmt.Errorf( + "milestone returned no recording servers: body=%s", + truncateMilestoneLogBody(body), + ) + } + + recordingServer := recordingServersResponse.Array[0] + + recordingServerID := strings.TrimSpace(recordingServer.ID) + recordingServerName := strings.TrimSpace(recordingServer.DisplayName) + + if recordingServerName == "" { + recordingServerName = strings.TrimSpace(recordingServer.Name) + } + + recordingServerVersion := strings.TrimSpace(recordingServer.Version) + + if recordingServerVersion == "" { + recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion) + } + + if recordingServerVersion == "" { + recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion) + } + + if recordingServerID == "" { + return "", "", "", fmt.Errorf( + "milestone returned recording server without id: body=%s", + truncateMilestoneLogBody(body), + ) + } + + log.Printf( + "Milestone recording server received successfully: id=%s displayName=%q version=%q", + recordingServerID, + recordingServerName, + recordingServerVersion, + ) + + return recordingServerID, recordingServerName, recordingServerVersion, nil +} + +func (s *Server) requestMilestoneHardwareDevices( + ctx context.Context, + host string, + recordingServerID string, + accessToken string, + skipTLSVerify bool, +) ([]milestoneHardwareDevice, error) { + hardwareItems, err := s.requestMilestoneHardware( + ctx, + host, + recordingServerID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + result := make([]milestoneHardwareDevice, 0, len(hardwareItems)) + + for _, hardware := range hardwareItems { + hardwareID := strings.TrimSpace(hardware.ID) + if hardwareID == "" { + hardwareID = strings.TrimSpace(hardware.Relations.Self.ID) + } + + if hardwareID == "" { + log.Printf("Milestone hardware skipped: missing hardware id") + continue + } + + settings, err := s.requestMilestoneHardwareDriverSettings( + ctx, + host, + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + hardwareDetails, err := s.requestMilestoneHardwareDetails( + ctx, + host, + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + displayName := strings.TrimSpace(hardware.DisplayName) + if displayName == "" { + displayName = strings.TrimSpace(hardware.Name) + } + + model := strings.TrimSpace(settings.DeviceModelName) + if model == "" { + model = strings.TrimSpace(settings.DetectedModelName) + } + + manufacturer := normalizeMilestoneManufacturer( + settings.DeviceManufacturer, + model, + ) + + if manufacturer == "" { + manufacturer = normalizeMilestoneManufacturer( + settings.ProductID, + model, + ) + } + + if manufacturer == "" { + manufacturer = extractMilestoneManufacturerFromName(model) + } + + deviceName := strings.TrimSpace(hardware.DeviceName) + if deviceName == "" { + deviceName = displayName + } + + enabled := true + + if hardware.Enabled != nil { + enabled = *hardware.Enabled + } + + if hardware.Disabled != nil { + enabled = !*hardware.Disabled + } + + hardwareAddress := strings.TrimSpace(milestoneStringValue(hardwareDetails["address"])) + if hardwareAddress == "" { + hardwareAddress = hardware.Address + } + + ipAddress, port := extractMilestoneHardwareAddressParts(hardwareAddress) + + embeddedCameras := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "cameras", + ) + + embeddedMicrophones := milestoneNamedDevicesFromHardwareCollection( + hardwareDetails, + "microphones", + ) + + camerasWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "cameras", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + microphonesWithDisabled, err := s.requestMilestoneNamedDevicesByHardware( + ctx, + host, + "microphones", + hardwareID, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + cameras := mergeMilestoneNamedDevices( + embeddedCameras, + camerasWithDisabled, + ) + + microphones := mergeMilestoneNamedDevices( + embeddedMicrophones, + microphonesWithDisabled, + ) + + result = append(result, milestoneHardwareDevice{ + HardwareID: hardwareID, + RecordingServerID: recordingServerID, + DisplayName: displayName, + DeviceName: deviceName, + Manufacturer: manufacturer, + MacAddress: strings.TrimSpace(settings.MacAddress), + IPAddress: ipAddress, + Port: port, + DeviceModelName: model, + SerialNumber: strings.TrimSpace(settings.SerialNumber), + FirmwareVersion: strings.TrimSpace(settings.FirmwareVersion), + Enabled: enabled, + Cameras: cameras, + Microphones: microphones, + }) + } + + cameraCount := 0 + microphoneCount := 0 + + for _, hardwareDevice := range result { + cameraCount += len(hardwareDevice.Cameras) + microphoneCount += len(hardwareDevice.Microphones) + } + + log.Printf( + "Milestone hardware loaded successfully: recordingServerId=%s count=%d cameras=%d microphones=%d", + recordingServerID, + len(result), + cameraCount, + microphoneCount, + ) + + return result, nil +} + +func (s *Server) requestMilestoneNamedDevicesByHardware( + ctx context.Context, + host string, + collection string, + hardwareID string, + accessToken string, + skipTLSVerify bool, +) ([]milestoneNamedDevice, error) { + switch collection { + case "cameras", "microphones": + default: + return nil, fmt.Errorf("unsupported milestone device collection: %s", collection) + } + + requestURL := strings.TrimRight(host, "/") + + "/API/rest/v1/" + + collection + + "?disabled" + + log.Printf( + "Requesting Milestone %s with disabled: url=%s hardwareId=%s skipTLSVerify=%t", + collection, + requestURL, + hardwareID, + skipTLSVerify, + ) + + body, err := requestMilestoneJSON( + ctx, + requestURL, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + return milestoneNamedDevicesFromListResponse(body, collection, hardwareID) +} + +func extractMilestoneHardwareIPAddress(value string) string { + host, _ := extractMilestoneHardwareAddressParts(value) + return host +} + +func defaultMilestonePortForScheme(scheme string) string { + switch strings.ToLower(strings.TrimSpace(scheme)) { + case "https": + return "443" + case "http": + return "80" + default: + return "80" + } +} + +func extractMilestoneHardwareAddressParts(value string) (string, string) { + value = strings.TrimSpace(value) + if value == "" { + return "", "" + } + + parsedURL, err := url.Parse(value) + if err == nil && parsedURL.Hostname() != "" { + scheme := strings.ToLower(strings.TrimSpace(parsedURL.Scheme)) + if scheme == "" { + scheme = "http" + } + + port := strings.TrimSpace(parsedURL.Port()) + if port == "" { + port = defaultMilestonePortForScheme(scheme) + } + + return strings.TrimSpace(parsedURL.Hostname()), port + } + + scheme := "http" + lowerValue := strings.ToLower(value) + + if strings.HasPrefix(lowerValue, "https://") { + scheme = "https" + } + + value = strings.TrimPrefix(value, "http://") + value = strings.TrimPrefix(value, "https://") + value = strings.TrimRight(value, "/") + + if host, port, found := strings.Cut(value, ":"); found { + port = strings.TrimSpace(port) + + if isOnlyDigits(port) { + return strings.TrimSpace(host), port + } + } + + return strings.TrimSpace(value), defaultMilestonePortForScheme(scheme) +} + +func extractMilestoneHardwarePort(value string) string { + _, port := extractMilestoneHardwareAddressParts(value) + return port +} + +func (s *Server) requestMilestoneHardware( + ctx context.Context, + host string, + recordingServerID string, + accessToken string, + skipTLSVerify bool, +) ([]milestoneHardware, error) { + requestURL := strings.TrimRight(host, "/") + + "/API/rest/v1/recordingServers/" + + url.PathEscape(recordingServerID) + + "/hardware?disabled" + + log.Printf( + "Requesting Milestone hardware: url=%s skipTLSVerify=%t", + requestURL, + skipTLSVerify, + ) + + body, err := requestMilestoneJSON( + ctx, + requestURL, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + var hardwareResponse milestoneHardwareResponse + if err := json.Unmarshal(body, &hardwareResponse); err != nil { + return nil, fmt.Errorf( + "milestone hardware response could not be parsed: body=%s error=%w", + truncateMilestoneLogBody(body), + err, + ) + } + + return hardwareResponse.Array, nil +} + +func (s *Server) requestMilestoneHardwareDetails( + ctx context.Context, + host string, + hardwareID string, + accessToken string, + skipTLSVerify bool, +) (map[string]any, error) { + requestURL := strings.TrimRight(host, "/") + + "/API/rest/v1/hardware/" + + url.PathEscape(hardwareID) + + log.Printf( + "Requesting Milestone hardware details: url=%s skipTLSVerify=%t", + requestURL, + skipTLSVerify, + ) + + body, err := requestMilestoneJSON( + ctx, + requestURL, + accessToken, + skipTLSVerify, + ) + if err != nil { + return nil, err + } + + var envelope milestoneHardwareUpdateEnvelope + if err := json.Unmarshal(body, &envelope); err == nil && envelope.Data != nil { + return envelope.Data, nil + } + + var direct map[string]any + if err := json.Unmarshal(body, &direct); err != nil { + return nil, fmt.Errorf( + "milestone hardware details response could not be parsed: body=%s error=%w", + truncateMilestoneLogBody(body), + err, + ) + } + + if data, ok := direct["data"].(map[string]any); ok { + return data, nil + } + + return direct, nil +} + +func (s *Server) requestMilestoneHardwareDriverSettings( + ctx context.Context, + host string, + hardwareID string, + accessToken string, + skipTLSVerify bool, +) (milestoneHardwareDriverSettings, error) { + requestURL := strings.TrimRight(host, "/") + + "/API/rest/v1/hardware/" + + url.PathEscape(hardwareID) + + "/hardwareDriverSettings" + + log.Printf( + "Requesting Milestone hardware driver settings: url=%s skipTLSVerify=%t", + requestURL, + skipTLSVerify, + ) + + body, err := requestMilestoneJSON( + ctx, + requestURL, + accessToken, + skipTLSVerify, + ) + if err != nil { + return milestoneHardwareDriverSettings{}, err + } + + var settingsResponse milestoneHardwareDriverSettingsResponse + if err := json.Unmarshal(body, &settingsResponse); err != nil { + return milestoneHardwareDriverSettings{}, fmt.Errorf( + "milestone hardware driver settings response could not be parsed: body=%s error=%w", + truncateMilestoneLogBody(body), + err, + ) + } + + if len(settingsResponse.Array) == 0 { + return milestoneHardwareDriverSettings{}, fmt.Errorf( + "milestone returned no hardware driver settings: hardwareId=%s body=%s", + hardwareID, + truncateMilestoneLogBody(body), + ) + } + + return settingsResponse.Array[0].HardwareDriverSettings, nil +} + +func requestMilestoneJSON( + ctx context.Context, + requestURL string, + accessToken string, + skipTLSVerify bool, +) ([]byte, error) { + request, err := http.NewRequestWithContext( + ctx, + http.MethodGet, + requestURL, + nil, + ) + if err != nil { + return nil, err + } + + request.Header.Set("Authorization", "Bearer "+accessToken) + request.Header.Set("Accept", "application/json") + + client := milestoneHTTPClient(skipTLSVerify) + + response, err := client.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 request failed: url=%s status=%d body=%s", + requestURL, + response.StatusCode, + truncateMilestoneLogBody(body), + ) + } + + return body, nil +} + +func syncMilestoneHardwareDevices( + ctx context.Context, + tx pgx.Tx, + hardwareDevices []milestoneHardwareDevice, +) (int, error) { + syncedCount := 0 + + for _, hardwareDevice := range hardwareDevices { + hardwareDevice.HardwareID = strings.TrimSpace(hardwareDevice.HardwareID) + hardwareDevice.RecordingServerID = strings.TrimSpace(hardwareDevice.RecordingServerID) + hardwareDevice.DisplayName = strings.TrimSpace(hardwareDevice.DisplayName) + hardwareDevice.DeviceName = strings.TrimSpace(hardwareDevice.DeviceName) + hardwareDevice.Manufacturer = strings.TrimSpace(hardwareDevice.Manufacturer) + hardwareDevice.DeviceModelName = strings.TrimSpace(hardwareDevice.DeviceModelName) + hardwareDevice.SerialNumber = strings.TrimSpace(hardwareDevice.SerialNumber) + hardwareDevice.MacAddress = strings.TrimSpace(hardwareDevice.MacAddress) + hardwareDevice.IPAddress = strings.TrimSpace(hardwareDevice.IPAddress) + hardwareDevice.Port = strings.TrimSpace(hardwareDevice.Port) + hardwareDevice.FirmwareVersion = strings.TrimSpace(hardwareDevice.FirmwareVersion) + + if hardwareDevice.HardwareID == "" { + continue + } + + if hardwareDevice.Manufacturer != "" { + if err := ensureOptionalDeviceLookupValue( + ctx, + tx, + "device_manufacturers", + hardwareDevice.Manufacturer, + ); err != nil { + return syncedCount, err + } + } + + synced, err := createOrUpdateMilestoneHardwareDevice(ctx, tx, hardwareDevice) + if err != nil { + return syncedCount, err + } + + if err := syncMilestoneHardwareChildDevices(ctx, tx, hardwareDevice); err != nil { + return syncedCount, err + } + + if synced { + syncedCount++ + } + } + + return syncedCount, nil +} + +func syncMilestoneHardwareChildDevices( + ctx context.Context, + tx pgx.Tx, + hardwareDevice milestoneHardwareDevice, +) error { + hardwareID := strings.TrimSpace(hardwareDevice.HardwareID) + if hardwareID == "" { + return nil + } + + _, err := tx.Exec( + ctx, + ` + DELETE FROM milestone_hardware_child_devices + WHERE milestone_hardware_id = $1 + `, + hardwareID, + ) + if err != nil { + return err + } + + for _, camera := range hardwareDevice.Cameras { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "camera", + camera, + ); err != nil { + return err + } + } + + for _, microphone := range hardwareDevice.Microphones { + if err := upsertMilestoneHardwareChildDevice( + ctx, + tx, + hardwareID, + "microphone", + microphone, + ); err != nil { + return err + } + } + + return nil +} + +func upsertMilestoneHardwareChildDevice( + ctx context.Context, + tx pgx.Tx, + hardwareID string, + deviceType string, + childDevice milestoneNamedDevice, +) error { + milestoneDeviceID := strings.TrimSpace(childDevice.ID) + if milestoneDeviceID == "" { + return nil + } + + name := strings.TrimSpace(childDevice.Name) + displayName := strings.TrimSpace(childDevice.DisplayName) + shortName := strings.TrimSpace(childDevice.ShortName) + + if name == "" { + name = displayName + } + if name == "" { + name = shortName + } + if displayName == "" { + displayName = name + } + if shortName == "" { + shortName = name + } + + _, err := tx.Exec( + ctx, + ` + INSERT INTO milestone_hardware_child_devices ( + milestone_hardware_id, + milestone_device_id, + device_type, + name, + display_name, + short_name, + enabled, + recording_enabled, + recording_storage_id + ) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (milestone_hardware_id, milestone_device_id, device_type) + DO UPDATE SET + name = EXCLUDED.name, + display_name = EXCLUDED.display_name, + short_name = EXCLUDED.short_name, + enabled = EXCLUDED.enabled, + recording_enabled = EXCLUDED.recording_enabled, + recording_storage_id = EXCLUDED.recording_storage_id, + updated_at = now() + `, + hardwareID, + milestoneDeviceID, + deviceType, + name, + displayName, + shortName, + childDevice.Enabled, + childDevice.RecordingEnabled, + strings.TrimSpace(childDevice.RecordingStorageID), + ) + + return err +} + +func createOrUpdateMilestoneHardwareDevice( + ctx context.Context, + tx pgx.Tx, + hardwareDevice milestoneHardwareDevice, +) (bool, error) { + inventoryNumber, err := buildMilestoneInventoryNumber(ctx, tx, hardwareDevice) + if err != nil { + return false, err + } + + commandTag, err := tx.Exec( + ctx, + ` + INSERT INTO devices ( + inventory_number, + manufacturer, + model, + serial_number, + mac_address, + ip_address, + milestone_port, + location, + loan_status, + is_bao_device, + comment, + firmware_version, + milestone_recording_server_id, + milestone_hardware_id, + milestone_display_name, + milestone_enabled, + milestone_synced_at + ) + VALUES ( + $1, + $2, + $3, + $4, + $5, + $6, + $7, + '', + 'Verfügbar', + false, + '', + $8, + $9, + $10, + $11, + $12, + now() + ) + ON CONFLICT (milestone_hardware_id) + WHERE milestone_hardware_id <> '' + DO UPDATE SET + inventory_number = EXCLUDED.inventory_number, + manufacturer = EXCLUDED.manufacturer, + model = EXCLUDED.model, + serial_number = EXCLUDED.serial_number, + mac_address = EXCLUDED.mac_address, + ip_address = EXCLUDED.ip_address, + milestone_port = EXCLUDED.milestone_port, + firmware_version = EXCLUDED.firmware_version, + milestone_recording_server_id = EXCLUDED.milestone_recording_server_id, + milestone_display_name = EXCLUDED.milestone_display_name, + milestone_enabled = EXCLUDED.milestone_enabled, + milestone_synced_at = now(), + updated_at = now() + `, + inventoryNumber, + hardwareDevice.Manufacturer, + hardwareDevice.DeviceModelName, + hardwareDevice.SerialNumber, + hardwareDevice.MacAddress, + hardwareDevice.IPAddress, + hardwareDevice.Port, + hardwareDevice.FirmwareVersion, + hardwareDevice.RecordingServerID, + hardwareDevice.HardwareID, + hardwareDevice.DisplayName, + hardwareDevice.Enabled, + ) + if err != nil { + return false, err + } + + return commandTag.RowsAffected() > 0, nil +} diff --git a/backend/routes.go b/backend/routes.go index f66cfb1..1d6fada 100644 --- a/backend/routes.go +++ b/backend/routes.go @@ -2,7 +2,9 @@ package main -import "net/http" +import ( + "net/http" +) func (s *Server) Routes() http.Handler { mux := http.NewServeMux() @@ -40,6 +42,15 @@ func (s *Server) Routes() http.Handler { s.requireAuth(s.handleUpdateMilestoneChildDevice), ) + mux.HandleFunc( + "GET /devices/{id}/milestone-cameras/{childId}", + s.requireAuth(s.handleGetMilestoneCameraDetails), + ) + mux.HandleFunc( + "PATCH /devices/{id}/milestone-cameras/{childId}", + s.requireAuth(s.handleUpdateMilestoneCameraDetails), + ) + mux.HandleFunc("GET /devices/firmware-check", s.requireAuth(s.handleGetCameraFirmwareCheckState)) mux.HandleFunc("POST /devices/firmware-check", s.requireAuth(s.handleCheckCameraFirmware)) @@ -103,6 +114,39 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("PUT /admin/milestone", s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneSettings))) mux.HandleFunc("POST /admin/milestone/token", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneToken))) mux.HandleFunc("POST /admin/milestone/token/stream", s.requireAuth(s.requireRight("administration:write", s.handleFetchMilestoneTokenStream))) + mux.HandleFunc( + "GET /admin/server-folders", + s.requireAuth(s.requireRight("administration:read", s.handleProxyListServerFolders)), + ) + mux.HandleFunc( + "POST /admin/server-folders", + s.requireAuth(s.requireRight("administration:write", s.handleProxyCreateServerFolder)), + ) + + mux.HandleFunc( + "GET /admin/milestone/storages", + s.requireAuth(s.requireRight("administration:read", s.handleListMilestoneStorages)), + ) + mux.HandleFunc( + "POST /admin/milestone/storages", + s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneStorageWithArchive)), + ) + mux.HandleFunc( + "POST /admin/milestone/storages/{id}/archive-storages", + s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneArchiveStorage)), + ) + mux.HandleFunc( + "POST /admin/milestone/storages/{id}/archive-storages/auto", + s.requireAuth(s.requireRight("administration:write", s.handleCreateMilestoneAutoArchiveStorage)), + ) + mux.HandleFunc( + "PATCH /admin/milestone/archive-storages/{id}", + s.requireAuth(s.requireRight("administration:write", s.handleUpdateMilestoneArchiveStorage)), + ) + mux.HandleFunc( + "GET /admin/milestone/storage-information/{id}", + s.requireAuth(s.requireRight("administration:read", s.handleGetMilestoneStorageInformation)), + ) return s.cors(mux) } diff --git a/backend/server_folders_proxy.go b/backend/server_folders_proxy.go new file mode 100644 index 0000000..28b56bf --- /dev/null +++ b/backend/server_folders_proxy.go @@ -0,0 +1,213 @@ +// backend/server_folders_proxy.go + +package main + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type serverFoldersAgentConfig struct { + MilestoneHost string + Scheme string + Port string + Token string +} + +func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) { + s.proxyServerFoldersRequest(w, r, http.MethodGet) +} + +func (s *Server) handleProxyCreateServerFolder(w http.ResponseWriter, r *http.Request) { + s.proxyServerFoldersRequest(w, r, http.MethodPost) +} + +func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) { + config, err := s.getServerFoldersAgentConfig(r.Context()) + if err != nil { + writeError(w, http.StatusBadGateway, "Server-Folders-Agent-Konfiguration konnte nicht geladen werden") + return + } + + targetURL, err := serverFoldersAgentURL(config, forwardedServerFoldersQuery(r.URL.Query())) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + + var body []byte + if method == http.MethodPost { + body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20)) + if err != nil { + writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden") + return + } + } + + request, err := http.NewRequestWithContext( + r.Context(), + method, + targetURL, + bytes.NewReader(body), + ) + if err != nil { + writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden") + return + } + + request.Header.Set("Accept", "application/json") + + if method == http.MethodPost { + 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) + 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 + } + + contentType := response.Header.Get("Content-Type") + if strings.TrimSpace(contentType) == "" { + contentType = "application/json" + } + + w.Header().Set("Content-Type", contentType) + w.WriteHeader(response.StatusCode) + _, _ = w.Write(responseBody) +} + +func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) { + var config serverFoldersAgentConfig + + err := s.db.QueryRow( + ctx, + ` + SELECT + host, + COALESCE(server_folders_agent_scheme, 'http'), + COALESCE(server_folders_agent_port, '8099'), + COALESCE(pgp_sym_decrypt(server_folders_agent_token_encrypted, $1), '') + FROM milestone_settings + WHERE id = 1 + LIMIT 1 + `, + s.milestoneEncryptionKey(), + ).Scan( + &config.MilestoneHost, + &config.Scheme, + &config.Port, + &config.Token, + ) + if err != nil { + return config, err + } + + config.Scheme = normalizeServerFoldersAgentScheme(config.Scheme) + if config.Scheme == "" { + config.Scheme = "http" + } + + config.Port = normalizeServerFoldersAgentPort(config.Port) + if config.Port == "" { + config.Port = "8099" + } + + config.MilestoneHost = strings.TrimSpace(config.MilestoneHost) + config.Token = strings.TrimSpace(config.Token) + + return config, nil +} + +func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (string, error) { + if strings.TrimSpace(config.MilestoneHost) == "" { + return "", fmt.Errorf("Milestone Host fehlt") + } + + normalizedMilestoneHost := strings.TrimSpace(config.MilestoneHost) + if !strings.Contains(normalizedMilestoneHost, "://") { + normalizedMilestoneHost = "http://" + normalizedMilestoneHost + } + + parsedMilestoneURL, err := url.Parse(normalizedMilestoneHost) + if err != nil || parsedMilestoneURL.Hostname() == "" { + return "", fmt.Errorf("Milestone Host ist ungültig") + } + + agentURL := url.URL{ + Scheme: config.Scheme, + Host: parsedMilestoneURL.Hostname() + ":" + config.Port, + Path: "/server-folders", + RawQuery: rawQuery, + } + + return agentURL.String(), nil +} + +func forwardedServerFoldersQuery(values url.Values) string { + // rootType=storage/archive wird bewusst weitergeleitet. + // Nur Verbindungs-/Auth-Parameter werden entfernt. + nextValues := url.Values{} + + for key, value := range values { + switch strings.ToLower(strings.TrimSpace(key)) { + case "schema", "scheme", "agentschema", "agentscheme", "port", "agentport", "token", "agenttoken": + continue + default: + for _, item := range value { + nextValues.Add(key, item) + } + } + } + + return nextValues.Encode() +} + +func normalizeServerFoldersAgentScheme(value string) string { + value = strings.TrimSpace(strings.ToLower(value)) + + switch value { + case "", "http", "https": + return value + default: + return "" + } +} + +func normalizeServerFoldersAgentPort(value string) string { + value = strings.TrimSpace(value) + value = strings.TrimPrefix(value, ":") + + if value == "" { + return "" + } + + for _, char := range value { + if char < '0' || char > '9' { + return "" + } + } + + return value +} diff --git a/backend/setup/main.go b/backend/setup/main.go index aa1c376..697e198 100644 --- a/backend/setup/main.go +++ b/backend/setup/main.go @@ -358,6 +358,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { 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(), @@ -498,11 +500,30 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { server_version TEXT NOT NULL DEFAULT '', server_updated_at TIMESTAMPTZ, + server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http', + server_folders_agent_port TEXT NOT NULL DEFAULT '8099', + server_folders_agent_token_encrypted BYTEA, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) `, + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_agent_scheme TEXT NOT NULL DEFAULT 'http' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_agent_port TEXT NOT NULL DEFAULT '8099' + `, + + ` + ALTER TABLE milestone_settings + ADD COLUMN IF NOT EXISTS server_folders_agent_token_encrypted BYTEA + `, + ` CREATE TABLE IF NOT EXISTS notification_preferences ( user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, @@ -608,6 +629,8 @@ func createSchema(ctx context.Context, db *pgxpool.Pool) error { `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_hardware_id ON milestone_hardware_child_devices(milestone_hardware_id)`, `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_device_id ON milestone_hardware_child_devices(milestone_device_id)`, + `CREATE INDEX IF NOT EXISTS idx_milestone_child_devices_recording_storage_id ON milestone_hardware_child_devices(recording_storage_id)`, + `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_inventory_number_unique ON devices(lower(inventory_number))`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_serial_number_unique ON devices(lower(serial_number)) WHERE serial_number <> ''`, `CREATE UNIQUE INDEX IF NOT EXISTS idx_devices_mac_address_unique ON devices(lower(mac_address)) WHERE mac_address <> ''`, diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 29aa7e3..ee7a0a7 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -18,6 +18,7 @@ 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 { hasRight } from './components/permissions' const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8080' @@ -433,6 +434,17 @@ function App() { } /> + + + + } + /> + > + right?: string + isChild?: boolean +} + +const navigation: NavigationItem[] = [ { name: 'Dashboard', href: '/', @@ -40,6 +49,13 @@ const navigation = [ icon: VideoCameraIcon, right: 'operations:read', }, + { + name: 'Milestone-Speicher', + href: '/einsaetze/milestone-speicher', + icon: ServerStackIcon, + right: 'administration:read', + isChild: true, + }, { name: 'Kalender', href: '/kalender', @@ -136,11 +152,20 @@ function SidebarContent({ isActive ? 'bg-white/5 text-white' : 'text-gray-400 hover:bg-white/5 hover:text-white', - 'group flex gap-x-3 rounded-md p-2 text-sm/6 font-semibold', + item.isChild + ? 'ml-7 gap-x-2 px-2 py-1.5 text-sm/6 font-medium' + : 'gap-x-3 p-2 text-sm/6 font-semibold', + 'group flex rounded-md', ) } > -