// backend\milestone_api.go package main import ( "bytes" "context" "database/sql" "encoding/json" "errors" "io" "net/http" "net/url" "strings" "time" "github.com/jackc/pgx/v5" ) type milestoneRuntimeConfig struct { Host string AccessToken string TokenType string SkipTLSVerify bool TokenExpiresAt *time.Time } type milestoneHardwarePatchDevice struct { ID string InventoryNumber string MilestoneHardwareID string MilestoneDisplayName string IPAddress string MilestonePort string MilestoneEnabled bool } type updateMilestoneHardwareRequest struct { Name *string `json:"name"` DisplayName *string `json:"displayName"` MilestoneDisplayName *string `json:"milestoneDisplayName"` IPAddress *string `json:"ipAddress"` Address *string `json:"address"` Port *string `json:"port"` MilestonePort *string `json:"milestonePort"` Enabled *bool `json:"enabled"` } func (s *Server) getMilestoneHardwarePatchDevice( ctx context.Context, deviceID string, ) (milestoneHardwarePatchDevice, error) { var device milestoneHardwarePatchDevice err := s.db.QueryRow( ctx, ` SELECT id::TEXT, inventory_number, COALESCE(milestone_hardware_id, ''), COALESCE(milestone_display_name, ''), COALESCE(ip_address, ''), COALESCE(milestone_port, ''), milestone_enabled FROM devices WHERE id = $1 LIMIT 1 `, deviceID, ).Scan( &device.ID, &device.InventoryNumber, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.IPAddress, &device.MilestonePort, &device.MilestoneEnabled, ) return device, err } func (s *Server) handleUpdateMilestoneHardware(w http.ResponseWriter, r *http.Request) { user, ok := userFromContext(r.Context()) if !ok { writeError(w, http.StatusUnauthorized, "Unauthorized") return } deviceID := strings.TrimSpace(r.PathValue("id")) if deviceID == "" { writeError(w, http.StatusBadRequest, "Geräte-ID fehlt") return } var input updateMilestoneHardwareRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } if input.Name == nil && input.DisplayName == nil && input.MilestoneDisplayName == nil && input.IPAddress == nil && input.Address == nil && input.Port == nil && input.MilestonePort == nil && input.Enabled == nil { writeError(w, http.StatusBadRequest, "Keine Milestone-Felder zum Aktualisieren übergeben") return } device, err := s.getMilestoneHardwarePatchDevice(r.Context(), deviceID) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusNotFound, "Gerät wurde nicht gefunden") return } if err != nil { writeError(w, http.StatusInternalServerError, "Gerät konnte nicht geladen werden") return } if strings.TrimSpace(device.MilestoneHardwareID) == "" { writeError(w, http.StatusBadRequest, "Dieses Gerät ist kein Milestone-Gerät") return } nextDisplayName := strings.TrimSpace(device.MilestoneDisplayName) nextIPAddress := strings.TrimSpace(device.IPAddress) nextPort := strings.TrimSpace(device.MilestonePort) nextEnabled := device.MilestoneEnabled payload := map[string]any{} changes := []DeviceHistoryChange{} requestedDisplayName := input.Name if requestedDisplayName == nil { requestedDisplayName = input.DisplayName } if requestedDisplayName == nil { requestedDisplayName = input.MilestoneDisplayName } 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) { changes = appendHistoryChange( changes, "milestoneDisplayName", "Milestone-Anzeigename", device.MilestoneDisplayName, nextDisplayName, ) } } requestedAddress := input.Address if requestedAddress == nil { requestedAddress = input.IPAddress } requestedPort := input.Port if requestedPort == nil { requestedPort = input.MilestonePort } if requestedAddress != nil || requestedPort != nil { addressValue := device.IPAddress if requestedAddress != nil { addressValue = *requestedAddress } portValue := device.MilestonePort if requestedPort != nil { portValue = *requestedPort } nextAddress := normalizeMilestoneHardwareAddress(addressValue, portValue) nextIPAddress, parsedPort := extractMilestoneHardwareAddressParts(nextAddress) nextPort = normalizeMilestonePort(portValue) if nextPort == "" { nextPort = parsedPort } if nextIPAddress == "" { nextIPAddress = strings.TrimSpace(addressValue) } // Wenn der Client Adresse oder Port sendet, immer an Milestone weitergeben. payload["address"] = nextAddress if nextIPAddress != strings.TrimSpace(device.IPAddress) { changes = appendHistoryChange( changes, "ipAddress", "IP-Adresse", device.IPAddress, nextIPAddress, ) } if nextPort != strings.TrimSpace(device.MilestonePort) { changes = appendHistoryChange( changes, "milestonePort", "Milestone-Port", device.MilestonePort, nextPort, ) } } if input.Enabled != nil { nextEnabled = *input.Enabled // Wenn der Client den Status sendet, immer an Milestone weitergeben. payload["enabled"] = nextEnabled if nextEnabled != device.MilestoneEnabled { changes = appendHistoryChange( changes, "milestoneEnabled", "Milestone-Status", formatHistoryBool(device.MilestoneEnabled), formatHistoryBool(nextEnabled), ) } } if len(payload) == 0 { writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "id": device.ID, "milestoneDisplayName": device.MilestoneDisplayName, "ipAddress": device.IPAddress, "milestonePort": device.MilestonePort, "milestoneEnabled": device.MilestoneEnabled, }) return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") return } if err != nil { s.logAndWriteUserError( w, r, user, http.StatusBadGateway, "milestone_hardware", "load_or_refresh_token", "Milestone-Token konnte nicht geladen oder erneuert werden", err, LogFields{ "deviceId": device.ID, "inventoryNumber": device.InventoryNumber, "milestoneHardwareId": device.MilestoneHardwareID, }, ) return } if strings.TrimSpace(config.Host) == "" { writeError(w, http.StatusBadRequest, "Milestone Host fehlt") return } if strings.TrimSpace(config.AccessToken) == "" { writeError(w, http.StatusBadRequest, "Milestone-Token fehlt und konnte nicht automatisch erneuert werden") return } if err := patchMilestoneHardware( r.Context(), config, device.MilestoneHardwareID, payload, ); err != nil { s.logAndWriteUserError( w, r, user, http.StatusBadGateway, "milestone_hardware", "patch_hardware", "Milestone-Gerät konnte nicht aktualisiert werden", err, LogFields{ "deviceId": device.ID, "inventoryNumber": device.InventoryNumber, "milestoneHardwareId": device.MilestoneHardwareID, "payload": payload, }, ) return } tx, err := s.db.Begin(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") return } defer tx.Rollback(r.Context()) _, err = tx.Exec( r.Context(), ` UPDATE devices SET milestone_display_name = $2, ip_address = $3, milestone_port = $4, milestone_enabled = $5, milestone_synced_at = now(), updated_at = now() WHERE id = $1 `, device.ID, nextDisplayName, nextIPAddress, nextPort, nextEnabled, ) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") return } if len(changes) > 0 { if err := recordDeviceHistory(r.Context(), tx, device.ID, user, "updated", changes); err != nil { writeError(w, http.StatusInternalServerError, "Geräteverlauf konnte nicht gespeichert werden") return } } if err := tx.Commit(r.Context()); err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Gerät konnte lokal nicht aktualisiert werden") return } if err := s.publishDeviceChangeEvent( r.Context(), user.ID, device.ID, "device.milestone.hardware", "Milestone-Gerät aktualisiert", formatText("Gerät %s wurde in Milestone aktualisiert.", device.InventoryNumber), LogFields{ "inventoryNumber": device.InventoryNumber, "milestoneHardwareId": device.MilestoneHardwareID, "payload": payload, }, ); err != nil { logError("Device-SSE-Event konnte nicht gesendet werden", err, LogFields{ "deviceId": device.ID, "type": "device.milestone.hardware", }) } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "id": device.ID, "milestoneDisplayName": nextDisplayName, "ipAddress": nextIPAddress, "milestonePort": nextPort, "milestoneEnabled": nextEnabled, }) } func normalizeMilestoneHardwareAddress(value string, port string) string { value = strings.TrimSpace(value) port = normalizeMilestonePort(port) if value == "" || value == "-" { return "" } if !strings.HasPrefix(value, "http://") && !strings.HasPrefix(value, "https://") { value = "http://" + strings.TrimRight(value, "/") } parsedURL, err := url.Parse(value) if err != nil || parsedURL.Hostname() == "" { value = strings.TrimRight(value, "/") if port != "" && !strings.Contains(value, ":") { value += ":" + port } return value + "/" } scheme := strings.TrimSpace(parsedURL.Scheme) if scheme == "" { scheme = "http" } host := parsedURL.Hostname() if port == "" { port = parsedURL.Port() } hostPort := host if port != "" { hostPort += ":" + port } return scheme + "://" + hostPort + "/" } func normalizeMilestonePort(value string) string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, ":") if value == "-" { return "" } return value } const milestoneTokenRefreshBeforeExpiry = 2 * time.Minute func (s *Server) getMilestoneRuntimeConfig( ctx context.Context, ) (milestoneRuntimeConfig, error) { config, err := s.loadMilestoneRuntimeConfig(ctx) if err != nil { return config, err } if shouldRefreshMilestoneToken(config) { if err := s.refreshMilestoneRuntimeToken(ctx); err != nil { return config, err } return s.loadMilestoneRuntimeConfig(ctx) } return config, nil } func shouldRefreshMilestoneToken(config milestoneRuntimeConfig) bool { if strings.TrimSpace(config.AccessToken) == "" { return true } if config.TokenExpiresAt == nil { return true } return time.Now().After(config.TokenExpiresAt.Add(-milestoneTokenRefreshBeforeExpiry)) } func (s *Server) loadMilestoneRuntimeConfig( ctx context.Context, ) (milestoneRuntimeConfig, error) { var config milestoneRuntimeConfig var tokenExpiresAt sql.NullTime err := s.db.QueryRow( ctx, ` SELECT host, COALESCE(pgp_sym_decrypt(access_token_encrypted, $1), ''), COALESCE(token_type, ''), skip_tls_verify, token_expires_at FROM milestone_settings WHERE id = 1 LIMIT 1 `, s.milestoneEncryptionKey(), ).Scan( &config.Host, &config.AccessToken, &config.TokenType, &config.SkipTLSVerify, &tokenExpiresAt, ) if err != nil { return config, err } if tokenExpiresAt.Valid { config.TokenExpiresAt = &tokenExpiresAt.Time } return config, nil } func (s *Server) refreshMilestoneRuntimeToken(ctx context.Context) error { credentials, err := s.getMilestoneCredentials(ctx) if err != nil { return err } if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { return errors.New("Milestone-Zugangsdaten sind unvollständig") } tokenResponse, err := s.requestMilestoneToken(ctx, credentials) if err != nil { return err } accessToken := strings.TrimSpace(tokenResponse.AccessToken) if accessToken == "" { return errors.New("Milestone hat keinen Access-Token zurückgegeben") } 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) _, err = s.db.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(), updated_at = now() WHERE id = 1 `, accessToken, s.milestoneEncryptionKey(), tokenType, tokenExpiresAt, ) return err } func patchMilestoneHardware( ctx context.Context, config milestoneRuntimeConfig, hardwareID string, payload any, ) error { body, err := json.Marshal(payload) if err != nil { return err } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/hardware/" + url.PathEscape(hardwareID) 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 hardware patch failed", LogFields{ "statusCode": response.StatusCode, "responseBody": string(responseBody), "hardwareId": hardwareID, }, "milestone hardware patch failed: status=%d body=%s", response.StatusCode, string(responseBody), ) } return nil }