// backend\operations_milestone_setup.go package main import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "regexp" "strconv" "strings" "github.com/jackc/pgx/v5" ) type operationMilestoneSetupRequest struct { CameraDeviceID string `json:"cameraDeviceId"` RouterDeviceID string `json:"routerDeviceId"` SwitchboxDeviceID string `json:"switchboxDeviceId"` HardDriveDeviceID string `json:"hardDriveDeviceId"` LaptopDeviceID string `json:"laptopDeviceId"` MilestoneUserID string `json:"milestoneUserId"` MilestoneUserName string `json:"milestoneUserName"` MilestoneUserSID string `json:"milestoneUserSid"` MilestoneUserDN string `json:"milestoneUserDn"` } type operationMilestoneSetupEvent struct { Type string `json:"type"` Step string `json:"step,omitempty"` Status string `json:"status,omitempty"` Message string `json:"message,omitempty"` } type operationMilestoneSetupDevice struct { ID string InventoryNumber string Manufacturer string Model string IPAddress string PhoneNumber string ComputerName string DeviceCategory string MilestoneHardwareID string MilestoneDisplayName string MilestonePort string } type operationMilestoneRole struct { ID string Name string DisplayName string Created bool Data map[string]any } var operationMilestoneNumberPattern = regexp.MustCompile(`\d+`) var operationMilestoneDetailSuffixPattern = regexp.MustCompile(`\s*\([^)]*\)\s*$`) var operationMilestoneEvaluationRolePattern = regexp.MustCompile(`(?i)\bauswertung\s*([1-9]|10)\b`) func (s *Server) handleOperationMilestoneSetup(w http.ResponseWriter, r *http.Request) { operationID := strings.TrimSpace(r.PathValue("id")) if operationID == "" { writeError(w, http.StatusBadRequest, "Einsatz-ID fehlt") return } var input operationMilestoneSetupRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-cache") report := func(event operationMilestoneSetupEvent) { writeOperationMilestoneSetupEvent(w, event) } fail := func(step string, message string, err error) { if err != nil { logError("Milestone-Nachlauf fehlgeschlagen", err, LogFields{ "operationId": operationID, "step": step, }) } report(operationMilestoneSetupEvent{ Type: "error", Step: step, Status: "error", Message: message, }) } ctx := r.Context() operation, err := s.getOperationForMilestoneSetup(ctx, operationID) if errors.Is(err, pgx.ErrNoRows) { fail("viewGroup", "Einsatz wurde nicht gefunden", err) return } if err != nil { fail("viewGroup", "Einsatz konnte nicht geladen werden", err) return } config, err := s.getMilestoneRuntimeConfig(ctx) if errors.Is(err, pgx.ErrNoRows) { fail("viewGroup", "Milestone-Einstellungen sind noch nicht hinterlegt", err) return } if err != nil { fail("viewGroup", "Milestone-Token konnte nicht geladen oder erneuert werden", err) return } if strings.TrimSpace(config.Host) == "" { fail("viewGroup", "Milestone Host fehlt", nil) return } if strings.TrimSpace(config.AccessToken) == "" { fail("viewGroup", "Milestone-Token fehlt und konnte nicht automatisch erneuert werden", nil) return } report(operationMilestoneSetupEvent{ Type: "step", Step: "viewGroup", Status: "current", Message: "Ansichtsgruppe wird gesucht oder angelegt.", }) viewGroupID, err := s.ensureOperationMilestoneViewGroup(ctx, config, operation) if err != nil { fail("viewGroup", "Ansichtsgruppe konnte in Milestone nicht angelegt werden", err) return } report(operationMilestoneSetupEvent{ Type: "step", Step: "viewGroup", Status: "complete", Message: fmt.Sprintf("Ansichtsgruppe %q ist bereit.", operationMilestoneViewGroupName(operation)), }) var cameraDevice *operationMilestoneSetupDevice var routerDevice *operationMilestoneSetupDevice report(operationMilestoneSetupEvent{ Type: "step", Step: "cameraIp", Status: "current", Message: "Kamera-IP wird berechnet.", }) cameraDevice, routerDevice, err = s.updateOperationMilestoneCameraIP(ctx, config, operation, input) if err != nil { fail("cameraIp", "Kamera-IP konnte nicht gesetzt werden", err) return } if cameraDevice == nil || routerDevice == nil { report(operationMilestoneSetupEvent{ Type: "step", Step: "cameraIp", Status: "complete", Message: "Keine Kamera oder kein Router ausgewaehlt, Schritt uebersprungen.", }) } else { report(operationMilestoneSetupEvent{ Type: "step", Step: "cameraIp", Status: "complete", Message: fmt.Sprintf("Kamera-IP wurde auf %s gesetzt.", cameraDevice.IPAddress), }) } var targetRole *operationMilestoneRole report(operationMilestoneSetupEvent{ Type: "step", Step: "roleUser", Status: "current", Message: "Auswertungsrolle und AD-Benutzer werden geprueft.", }) targetRole, err = s.ensureOperationMilestoneRoleUser(ctx, config, operation, input) if err != nil { fail("roleUser", "AD-Benutzer konnte der Rolle nicht zugeordnet werden", err) return } if targetRole == nil { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleUser", Status: "complete", Message: "Kein Auswerter-Laptop ausgewaehlt, Schritt uebersprungen.", }) } else if targetRole.Created { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleUser", Status: "complete", Message: fmt.Sprintf("Rolle %q wurde angelegt.", operationMilestoneRoleDisplayName(*targetRole)), }) } else { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleUser", Status: "complete", Message: fmt.Sprintf("Rolle %q ist vorbereitet.", operationMilestoneRoleDisplayName(*targetRole)), }) } report(operationMilestoneSetupEvent{ Type: "step", Step: "roleCamera", Status: "current", Message: "Kamera wird der Rolle zugeordnet.", }) if targetRole == nil || cameraDevice == nil { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleCamera", Status: "complete", Message: "Keine Rolle oder Kamera verfuegbar, Schritt uebersprungen.", }) } else if message, err := s.assignOperationMilestoneCameraToRole(ctx, config, *targetRole, *cameraDevice); err != nil { fail("roleCamera", "Kamera konnte der Rolle nicht zugeordnet werden", err) return } else { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleCamera", Status: "complete", Message: message, }) } report(operationMilestoneSetupEvent{ Type: "step", Step: "roleViewGroup", Status: "current", Message: "Ansichtsgruppe wird der Rolle zugeordnet.", }) if targetRole == nil || viewGroupID == "" { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleViewGroup", Status: "complete", Message: "Keine Rolle oder Ansichtsgruppe verfuegbar, Schritt uebersprungen.", }) } else if message, err := s.assignOperationMilestoneViewGroupToRole(ctx, config, *targetRole, viewGroupID); err != nil { fail("roleViewGroup", "Ansichtsgruppe konnte der Rolle nicht zugeordnet werden", err) return } else { report(operationMilestoneSetupEvent{ Type: "step", Step: "roleViewGroup", Status: "complete", Message: message, }) } report(operationMilestoneSetupEvent{ Type: "step", Step: "otherRoles", Status: "current", Message: "Weitere Rollen werden geprueft.", }) otherRoleCount, err := s.countOperationMilestoneEvaluationRoles(ctx, config) if err != nil { fail("otherRoles", "Andere Rollen konnten nicht geprueft werden", err) return } report(operationMilestoneSetupEvent{ Type: "step", Step: "otherRoles", Status: "complete", Message: fmt.Sprintf("%d Auswertungsrollen wurden gefunden.", otherRoleCount), }) report(operationMilestoneSetupEvent{ Type: "done", Message: "Milestone-Nachlauf abgeschlossen.", }) } func writeOperationMilestoneSetupEvent(w http.ResponseWriter, event operationMilestoneSetupEvent) { if err := json.NewEncoder(w).Encode(event); err != nil { logError("Milestone-Nachlauf-Event konnte nicht geschrieben werden", err, nil) return } if flusher, ok := w.(http.Flusher); ok { flusher.Flush() } } func (s *Server) getOperationForMilestoneSetup(ctx context.Context, operationID string) (Operation, error) { var operation Operation err := s.db.QueryRow( ctx, ` SELECT id, operation_number, operation_name, offense, case_worker, target_object, kw_address, kw_latitude, kw_longitude, target_latitude, target_longitude, view_cone_fov, view_cone_length, operation_leader, operation_team, legend, camera, router, technology, switchbox, hard_drive, laptop, remark, milestone_storage, completed_at, created_at, updated_at FROM operations WHERE id = $1 LIMIT 1 `, operationID, ).Scan( &operation.ID, &operation.OperationNumber, &operation.OperationName, &operation.Offense, &operation.CaseWorker, &operation.TargetObject, &operation.KwAddress, &operation.KwLatitude, &operation.KwLongitude, &operation.TargetLatitude, &operation.TargetLongitude, &operation.ViewConeFOV, &operation.ViewConeLength, &operation.OperationLeader, &operation.OperationTeam, &operation.Legend, &operation.Camera, &operation.Router, &operation.Technology, &operation.Switchbox, &operation.HardDrive, &operation.Laptop, &operation.Remark, &operation.MilestoneStorage, &operation.CompletedAt, &operation.CreatedAt, &operation.UpdatedAt, ) return operation, err } func (s *Server) getOperationMilestoneSetupDevice( ctx context.Context, deviceID string, displayValue string, category string, ) (*operationMilestoneSetupDevice, error) { deviceID = strings.TrimSpace(deviceID) if deviceID != "" { device, err := s.getOperationMilestoneSetupDeviceByID(ctx, deviceID) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &device, nil } displayValue = normalizeOperationMilestoneEquipmentDisplayValue(displayValue) if displayValue == "" { return nil, nil } device, err := s.getOperationMilestoneSetupDeviceByDisplayValue(ctx, displayValue, category) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } return &device, nil } func (s *Server) getOperationMilestoneSetupDeviceByID( ctx context.Context, deviceID string, ) (operationMilestoneSetupDevice, error) { var device operationMilestoneSetupDevice err := s.db.QueryRow( ctx, ` SELECT id::TEXT, inventory_number, manufacturer, model, COALESCE(ip_address, ''), COALESCE(phone_number, ''), COALESCE(computer_name, ''), COALESCE(device_category, ''), COALESCE(milestone_hardware_id, ''), COALESCE(milestone_display_name, ''), COALESCE(milestone_port, '') FROM devices WHERE id = $1 LIMIT 1 `, deviceID, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.IPAddress, &device.PhoneNumber, &device.ComputerName, &device.DeviceCategory, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.MilestonePort, ) return device, err } func (s *Server) getOperationMilestoneSetupDeviceByDisplayValue( ctx context.Context, displayValue string, category string, ) (operationMilestoneSetupDevice, error) { var device operationMilestoneSetupDevice err := s.db.QueryRow( ctx, ` SELECT id::TEXT, inventory_number, manufacturer, model, COALESCE(ip_address, ''), COALESCE(phone_number, ''), COALESCE(computer_name, ''), COALESCE(device_category, ''), COALESCE(milestone_hardware_id, ''), COALESCE(milestone_display_name, ''), COALESCE(milestone_port, '') FROM devices WHERE lower(COALESCE(device_category, '')) = lower($2) AND ( inventory_number = $1 OR model = $1 OR COALESCE(milestone_display_name, '') = $1 OR COALESCE(computer_name, '') = $1 OR COALESCE(ip_address, '') = $1 ) ORDER BY updated_at DESC LIMIT 1 `, displayValue, category, ).Scan( &device.ID, &device.InventoryNumber, &device.Manufacturer, &device.Model, &device.IPAddress, &device.PhoneNumber, &device.ComputerName, &device.DeviceCategory, &device.MilestoneHardwareID, &device.MilestoneDisplayName, &device.MilestonePort, ) return device, err } func normalizeOperationMilestoneEquipmentDisplayValue(value string) string { value = strings.TrimSpace(value) for operationMilestoneDetailSuffixPattern.MatchString(value) { value = strings.TrimSpace(operationMilestoneDetailSuffixPattern.ReplaceAllString(value, "")) } return value } func (s *Server) updateOperationMilestoneCameraIP( ctx context.Context, config milestoneRuntimeConfig, operation Operation, input operationMilestoneSetupRequest, ) (*operationMilestoneSetupDevice, *operationMilestoneSetupDevice, error) { cameraDevice, err := s.getOperationMilestoneSetupDevice( ctx, input.CameraDeviceID, operation.Camera, "Kameras", ) if err != nil { return nil, nil, err } routerDevice, err := s.getOperationMilestoneSetupDevice( ctx, input.RouterDeviceID, operation.Router, "Router", ) if err != nil { return nil, nil, err } if cameraDevice == nil || routerDevice == nil { return cameraDevice, routerDevice, nil } if strings.TrimSpace(cameraDevice.MilestoneHardwareID) == "" { return cameraDevice, routerDevice, fmt.Errorf("Kamera %q ist kein Milestone-Hardwaregeraet", cameraDevice.InventoryNumber) } nextIP, err := operationMilestoneCameraIPAddress(*routerDevice, *cameraDevice) if err != nil { return cameraDevice, routerDevice, err } nextAddress := normalizeMilestoneHardwareAddress(nextIP, cameraDevice.MilestonePort) if err := updateMilestoneHardware( ctx, config, cameraDevice.MilestoneHardwareID, map[string]any{"address": nextAddress}, cameraDevice.MilestoneDisplayName, cameraDevice.MilestoneDisplayName, ); err != nil { return cameraDevice, routerDevice, err } if _, err := s.db.Exec( ctx, ` UPDATE devices SET ip_address = $2, milestone_synced_at = now(), updated_at = now() WHERE id = $1 `, cameraDevice.ID, nextIP, ); err != nil { return cameraDevice, routerDevice, err } cameraDevice.IPAddress = nextIP return cameraDevice, routerDevice, nil } func operationMilestoneCameraIPAddress( routerDevice operationMilestoneSetupDevice, cameraDevice operationMilestoneSetupDevice, ) (string, error) { routerIP := net.ParseIP(strings.TrimSpace(routerDevice.IPAddress)).To4() if routerIP == nil { return "", fmt.Errorf("Router %q hat keine gueltige IPv4-Adresse", routerDevice.InventoryNumber) } cameraNumber, err := operationMilestoneCameraNumber(cameraDevice) if err != nil { return "", err } return fmt.Sprintf( "%d.%d.%d.%d", routerIP[0], routerIP[1], routerIP[2], cameraNumber, ), nil } func operationMilestoneCameraNumber(device operationMilestoneSetupDevice) (int, error) { candidates := []string{ device.InventoryNumber, device.MilestoneDisplayName, device.Model, } for _, candidate := range candidates { matches := operationMilestoneNumberPattern.FindAllString(candidate, -1) if len(matches) == 0 { continue } value, err := strconv.Atoi(matches[len(matches)-1]) if err == nil && value > 0 && value < 255 { return value, nil } } return 0, fmt.Errorf("Kameranummer konnte fuer %q nicht ermittelt werden", device.InventoryNumber) } func operationMilestoneViewGroupName(operation Operation) string { operationNumber := operationMilestoneRoleName(operation) operationName := strings.TrimSpace(operation.OperationName) if operationName == "" { return operationNumber } if operationNumber == "" { return operationName } return operationNumber + " - " + operationName } func operationMilestoneRoleName(operation Operation) string { return strings.ReplaceAll(strings.TrimSpace(operation.OperationNumber), "-", "") } func (s *Server) ensureOperationMilestoneViewGroup( ctx context.Context, config milestoneRuntimeConfig, operation Operation, ) (string, error) { viewGroupName := operationMilestoneViewGroupName(operation) if viewGroupName == "" { return "", errors.New("Einsatznummer oder Einsatzname fehlt") } viewGroups, err := listOperationMilestoneViewGroups(ctx, config) if err != nil { return "", err } if existingID := findOperationMilestoneItemIDByName(viewGroups, viewGroupName); existingID != "" { return existingID, nil } parentID := operationMilestoneViewGroupParentID(viewGroups) payloads := []map[string]any{ { "name": viewGroupName, "displayName": viewGroupName, }, } if parentID != "" { payloads = append([]map[string]any{ { "name": viewGroupName, "displayName": viewGroupName, "parentId": parentID, }, }, payloads...) } var lastErr error for _, payload := range payloads { body, _, err := operationMilestoneRequestJSON( ctx, config, http.MethodPost, "/API/rest/v1/viewGroups", payload, ) if err != nil { lastErr = err continue } if id := milestoneItemID(decodeOperationMilestoneObject(body)); id != "" { return id, nil } viewGroups, err = listOperationMilestoneViewGroups(ctx, config) if err != nil { return "", err } if id := findOperationMilestoneItemIDByName(viewGroups, viewGroupName); id != "" { return id, nil } } if lastErr != nil { return "", lastErr } return "", fmt.Errorf("Milestone hat keine ID fuer Ansichtsgruppe %q zurueckgegeben", viewGroupName) } func listOperationMilestoneViewGroups( ctx context.Context, config milestoneRuntimeConfig, ) ([]map[string]any, error) { collected := []map[string]any{} visited := map[string]bool{} topLevel, err := listOperationMilestoneObjectsAtPath(ctx, config, "/API/rest/v1/viewGroups") if err == nil { collected = append(collected, topLevel...) for _, item := range topLevel { collectOperationMilestoneViewGroupChildren(ctx, config, item, visited, &collected) } } rootCandidates := []string{ strings.TrimSpace(os.Getenv("MILESTONE_VIEW_GROUP_ROOT_ID")), strings.TrimSpace(config.ServerID), "root", "00000000-0000-0000-0000-000000000000", } for _, rootID := range rootCandidates { if rootID == "" || visited[rootID] { continue } children, childErr := listOperationMilestoneObjectsAtPath( ctx, config, "/API/rest/v1/viewGroups/"+url.PathEscape(rootID)+"/viewGroups", ) if childErr != nil { continue } visited[rootID] = true collected = append(collected, children...) for _, item := range children { collectOperationMilestoneViewGroupChildren(ctx, config, item, visited, &collected) } } if len(collected) == 0 && err != nil { return nil, err } return collected, nil } func collectOperationMilestoneViewGroupChildren( ctx context.Context, config milestoneRuntimeConfig, item map[string]any, visited map[string]bool, collected *[]map[string]any, ) { id := milestoneItemID(item) if id == "" || visited[id] { return } visited[id] = true children, err := listOperationMilestoneObjectsAtPath( ctx, config, "/API/rest/v1/viewGroups/"+url.PathEscape(id)+"/viewGroups", ) if err != nil { return } *collected = append(*collected, children...) for _, child := range children { collectOperationMilestoneViewGroupChildren(ctx, config, child, visited, collected) } } func operationMilestoneViewGroupParentID(viewGroups []map[string]any) string { if parentID := strings.TrimSpace(os.Getenv("MILESTONE_VIEW_GROUP_ROOT_ID")); parentID != "" { return parentID } for _, item := range viewGroups { name := strings.ToLower(strings.TrimSpace(operationMilestoneItemDisplayName(item))) if name == "root" || name == "ansichten" || name == "views" { return milestoneItemID(item) } } return "" } func (s *Server) ensureOperationMilestoneRoleUser( ctx context.Context, config milestoneRuntimeConfig, operation Operation, input operationMilestoneSetupRequest, ) (*operationMilestoneRole, error) { laptopDevice, err := s.getOperationMilestoneSetupDevice( ctx, input.LaptopDeviceID, operation.Laptop, "Laptop", ) if err != nil { return nil, err } if laptopDevice == nil { return nil, nil } roleName := operationMilestoneEvaluationRoleName(*laptopDevice, operation) if roleName == "" { return nil, fmt.Errorf("Auswertungsrolle konnte aus Laptop %q nicht ermittelt werden", laptopDevice.InventoryNumber) } sourceRole, err := findOperationMilestoneRoleByName(ctx, config, roleName) if err != nil { return nil, err } if sourceRole == nil { operationRoleName := operationMilestoneRoleName(operation) if operationRoleName == "" { return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Einsatznummer fehlt", roleName) } role, err := ensureOperationMilestoneRole(ctx, config, operationRoleName) if err != nil { return nil, fmt.Errorf("Milestone-Rolle %q wurde nicht gefunden und Rolle %q konnte nicht angelegt werden: %w", roleName, operationRoleName, err) } role, err = getOperationMilestoneRole(ctx, config, role.ID) if err != nil { return nil, err } if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { roleUsers, listErr := listOperationMilestoneRoleUsers(ctx, config, role.ID) if listErr != nil { roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) } if operationMilestoneObjectByID(roleUsers, milestoneItemID(selectedUser)) == nil { if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, []map[string]any{selectedUser}); err != nil { return nil, err } roleUsers = appendOperationMilestoneObjectByID(roleUsers, selectedUser) } role.Data["users"] = roleUsers } return role, nil } targetUserName := strings.TrimSpace(laptopDevice.ComputerName) if targetUserName == "" { targetUserName = roleName } role, err := getOperationMilestoneRole(ctx, config, sourceRole.ID) if err != nil { return nil, err } sourceUsers, err := listOperationMilestoneRoleUsers(ctx, config, sourceRole.ID) if err != nil { return nil, err } candidateUsers := sourceUsers if hasOperationMilestoneUserSelection(input) { allRoleUsers, err := listOperationMilestoneUsersFromRoles(ctx, config) if err != nil { return nil, err } candidateUsers = appendOperationMilestoneObjectsByID(candidateUsers, allRoleUsers...) } usersToAssign := selectOperationMilestoneRoleUsersForAssignment(candidateUsers, targetUserName, input) if len(usersToAssign) == 0 { if selectedUser := operationMilestoneUserFromSetupInput(input); selectedUser != nil { usersToAssign = []map[string]any{selectedUser} } else { return nil, fmt.Errorf("AD-Benutzer %q wurde unter /roles/%s/users nicht gefunden", targetUserName, sourceRole.ID) } } roleUsers := sourceUsers if len(roleUsers) == 0 { roleUsers = operationMilestoneObjectListFromValue(role.Data["users"]) } usersToAdd := []map[string]any{} for _, targetUser := range usersToAssign { if milestoneItemID(targetUser) == "" { return nil, fmt.Errorf("AD-Benutzer %q hat keine Milestone-ID", targetUserName) } if operationMilestoneObjectByID(roleUsers, milestoneItemID(targetUser)) != nil { continue } usersToAdd = appendOperationMilestoneObjectByID(usersToAdd, targetUser) roleUsers = appendOperationMilestoneObjectByID(roleUsers, targetUser) } if len(usersToAdd) > 0 { if err := addOperationMilestoneRoleMembers(ctx, config, role.ID, usersToAdd); err != nil { return nil, err } } role.Data["users"] = roleUsers return role, nil } func selectOperationMilestoneRoleUsersForAssignment( users []map[string]any, preferredUserName string, input operationMilestoneSetupRequest, ) []map[string]any { if targetUser := findOperationMilestoneUserBySetupInput(users, input); targetUser != nil { return []map[string]any{targetUser} } if targetUser := findOperationMilestoneItemByName(users, preferredUserName); targetUser != nil { return []map[string]any{targetUser} } usersWithID := make([]map[string]any, 0, len(users)) for _, user := range users { if milestoneItemID(user) != "" { usersWithID = append(usersWithID, user) } } return usersWithID } func hasOperationMilestoneUserSelection(input operationMilestoneSetupRequest) bool { return strings.TrimSpace(input.MilestoneUserID) != "" || strings.TrimSpace(input.MilestoneUserName) != "" || strings.TrimSpace(input.MilestoneUserSID) != "" || strings.TrimSpace(input.MilestoneUserDN) != "" } func findOperationMilestoneUserBySetupInput( users []map[string]any, input operationMilestoneSetupRequest, ) map[string]any { candidates := operationMilestoneUserSearchCandidates(input) if len(candidates) == 0 { return nil } for _, user := range users { userValues := operationMilestoneUserComparableValues(user) for _, candidate := range candidates { for _, userValue := range userValues { if normalizeOperationMilestoneName(userValue) == normalizeOperationMilestoneName(candidate) { return user } } } } return nil } func operationMilestoneUserSearchCandidates(input operationMilestoneSetupRequest) []string { values := []string{ input.MilestoneUserID, input.MilestoneUserSID, input.MilestoneUserDN, input.MilestoneUserName, } candidates := make([]string, 0, len(values)) for _, value := range values { if value = strings.TrimSpace(value); value != "" { candidates = append(candidates, value) } } return candidates } func operationMilestoneUserComparableValues(user map[string]any) []string { values := []string{ milestoneItemID(user), operationMilestoneItemDisplayName(user), milestoneStringValue(user["name"]), milestoneStringValue(user["displayName"]), milestoneStringValue(user["userName"]), milestoneStringValue(user["username"]), milestoneStringValue(user["accountName"]), milestoneStringValue(user["loginName"]), milestoneStringValue(user["sid"]), milestoneStringValue(user["objectSid"]), milestoneStringValue(user["distinguishedName"]), } relations, _ := user["relations"].(map[string]any) for _, relationKey := range []string{"self", "user", "account"} { if relation, ok := relations[relationKey].(map[string]any); ok { values = append(values, milestoneStringValue(relation["id"])) } } return values } func operationMilestoneUserFromSetupInput(input operationMilestoneSetupRequest) map[string]any { if !hasOperationMilestoneUserSelection(input) { return nil } userID := strings.TrimSpace(input.MilestoneUserID) userSID := strings.TrimSpace(input.MilestoneUserSID) userDN := strings.TrimSpace(input.MilestoneUserDN) userName := strings.TrimSpace(input.MilestoneUserName) if userID == "" { userID = userSID } if userID == "" { userID = userDN } if userID == "" { userID = userName } if userName == "" { userName = userID } user := map[string]any{ "id": userID, "name": userName, "displayName": userName, "relations": map[string]any{ "self": map[string]any{ "type": "users", "id": userID, }, }, } if userSID != "" { user["sid"] = userSID user["objectSid"] = userSID } if userDN != "" { user["distinguishedName"] = userDN } return user } func listOperationMilestoneUsersFromRoles( ctx context.Context, config milestoneRuntimeConfig, ) ([]map[string]any, error) { roles, err := listOperationMilestoneRoles(ctx, config) if err != nil { return nil, err } users := []map[string]any{} for _, role := range roles { roleUsers, err := listOperationMilestoneRoleUsers(ctx, config, role.ID) if err != nil { return nil, err } users = appendOperationMilestoneObjectsByID(users, roleUsers...) } return users, nil } func operationMilestoneEvaluationRoleName( laptopDevice operationMilestoneSetupDevice, operation Operation, ) string { candidates := []string{ laptopDevice.ComputerName, laptopDevice.InventoryNumber, laptopDevice.Model, operation.Laptop, } for _, candidate := range candidates { match := operationMilestoneEvaluationRolePattern.FindStringSubmatch(candidate) if len(match) == 2 { return "Auswertung " + match[1] } } for _, candidate := range candidates { if value := strings.TrimSpace(candidate); value != "" { return normalizeOperationMilestoneEquipmentDisplayValue(value) } } return "" } func (s *Server) assignOperationMilestoneCameraToRole( ctx context.Context, config milestoneRuntimeConfig, role operationMilestoneRole, cameraDevice operationMilestoneSetupDevice, ) (string, error) { cameraID, err := s.getOperationMilestoneCameraDeviceID(ctx, cameraDevice.MilestoneHardwareID) if err != nil { return "", err } if cameraID == "" { return "", fmt.Errorf("Keine Milestone-Kamera fuer Hardware %q gefunden", cameraDevice.MilestoneHardwareID) } return operationMilestonePermissionWriteUnsupportedMessage(role, "Kamera"), nil } func (s *Server) assignOperationMilestoneViewGroupToRole( ctx context.Context, config milestoneRuntimeConfig, role operationMilestoneRole, viewGroupID string, ) (string, error) { if strings.TrimSpace(viewGroupID) == "" { return "", errors.New("Ansichtsgruppen-ID fehlt") } return operationMilestonePermissionWriteUnsupportedMessage(role, "Ansichtsgruppe"), nil } func operationMilestonePermissionWriteUnsupportedMessage( role operationMilestoneRole, objectLabel string, ) string { return fmt.Sprintf( "%s ist vorhanden. Berechtigungen fuer Rolle %q werden in Milestone ueber Security-Namespaces/Gruppenrechte verwaltet und nicht ueber das Rollenfeld gesetzt.", objectLabel, operationMilestoneRoleDisplayName(role), ) } func (s *Server) getOperationMilestoneCameraDeviceID( ctx context.Context, milestoneHardwareID string, ) (string, error) { var cameraID string err := s.db.QueryRow( ctx, ` SELECT COALESCE(milestone_device_id, '') FROM milestone_hardware_child_devices WHERE milestone_hardware_id = $1 AND lower(device_type) = 'camera' ORDER BY name ASC, display_name ASC LIMIT 1 `, milestoneHardwareID, ).Scan(&cameraID) if errors.Is(err, pgx.ErrNoRows) { return "", nil } return cameraID, err } func (s *Server) countOperationMilestoneEvaluationRoles( ctx context.Context, config milestoneRuntimeConfig, ) (int, error) { roles, err := listOperationMilestoneRoles(ctx, config) if err != nil { return 0, err } count := 0 for _, role := range roles { if operationMilestoneEvaluationRolePattern.MatchString(operationMilestoneRoleDisplayName(role)) { count++ } } return count, nil } func listOperationMilestoneRoles( ctx context.Context, config milestoneRuntimeConfig, ) ([]operationMilestoneRole, error) { items, err := listOperationMilestoneObjectsAtPath(ctx, config, "/API/rest/v1/roles") if err != nil { return nil, err } roles := make([]operationMilestoneRole, 0, len(items)) for _, item := range items { role := operationMilestoneRoleFromItem(item) if role.ID == "" { continue } roles = append(roles, role) } return roles, nil } func getOperationMilestoneRole( ctx context.Context, config milestoneRuntimeConfig, roleID string, ) (*operationMilestoneRole, error) { body, _, err := operationMilestoneRequestJSON( ctx, config, http.MethodGet, "/API/rest/v1/roles/"+url.PathEscape(roleID), nil, ) if err != nil { return nil, err } role := operationMilestoneRoleFromItem(decodeOperationMilestoneObject(body)) if role.ID == "" { return nil, fmt.Errorf("Milestone-Rolle %q konnte nicht geladen werden", roleID) } return &role, nil } func findOperationMilestoneRoleByName( ctx context.Context, config milestoneRuntimeConfig, roleName string, ) (*operationMilestoneRole, error) { roles, err := listOperationMilestoneRoles(ctx, config) if err != nil { return nil, err } normalizedRoleName := normalizeOperationMilestoneName(roleName) for _, role := range roles { if normalizeOperationMilestoneName(role.Name) == normalizedRoleName || normalizeOperationMilestoneName(role.DisplayName) == normalizedRoleName { return &role, nil } } return nil, nil } func ensureOperationMilestoneRole( ctx context.Context, config milestoneRuntimeConfig, roleName string, ) (*operationMilestoneRole, error) { roleName = strings.TrimSpace(roleName) if roleName == "" { return nil, errors.New("Rollenname fehlt") } if role, err := findOperationMilestoneRoleByName(ctx, config, roleName); err != nil { return nil, err } else if role != nil { return role, nil } payloads := []map[string]any{ { "name": roleName, "displayName": roleName, }, { "name": roleName, }, } var lastErr error for _, payload := range payloads { body, _, err := operationMilestoneRequestJSON( ctx, config, http.MethodPost, "/API/rest/v1/roles", payload, ) if err != nil { lastErr = err continue } role := operationMilestoneRoleFromItem(decodeOperationMilestoneObject(body)) if role.ID != "" { role.Created = true return &role, nil } roleFromList, err := findOperationMilestoneRoleByName(ctx, config, roleName) if err != nil { return nil, err } if roleFromList != nil { roleFromList.Created = true return roleFromList, nil } } if lastErr != nil { return nil, lastErr } return nil, fmt.Errorf("Milestone hat keine ID fuer Rolle %q zurueckgegeben", roleName) } func operationMilestoneRoleFromItem(item map[string]any) operationMilestoneRole { return operationMilestoneRole{ ID: milestoneItemID(item), Name: strings.TrimSpace(milestoneStringValue(item["name"])), DisplayName: strings.TrimSpace(milestoneStringValue(item["displayName"])), Data: item, } } func operationMilestoneRoleDisplayName(role operationMilestoneRole) string { if role.DisplayName != "" { return role.DisplayName } if role.Name != "" { return role.Name } return role.ID } func listOperationMilestoneRoleUsers( ctx context.Context, config milestoneRuntimeConfig, roleID string, ) ([]map[string]any, error) { return listOperationMilestoneObjectsAtPath( ctx, config, "/API/rest/v1/roles/"+url.PathEscape(roleID)+"/users", ) } func putOperationMilestoneRole( ctx context.Context, config milestoneRuntimeConfig, role operationMilestoneRole, users []map[string]any, ) error { if role.Data == nil { role.Data = map[string]any{} } roleName := strings.TrimSpace(role.Name) if roleName == "" { roleName = strings.TrimSpace(role.DisplayName) } if roleName == "" { roleName = role.ID } roleDisplayName := strings.TrimSpace(role.DisplayName) if roleDisplayName == "" { roleDisplayName = strings.TrimSpace(milestoneStringValue(role.Data["displayName"])) } if roleDisplayName == "" { roleDisplayName = roleName } relations, ok := role.Data["relations"].(map[string]any) if !ok || relations == nil { relations = map[string]any{ "self": map[string]any{ "type": "roles", "id": role.ID, }, } } payload := map[string]any{ "allowMobileClientLogOn": milestoneBoolValue(role.Data["allowMobileClientLogOn"]), "allowSmartClientLogOn": milestoneBoolValue(role.Data["allowSmartClientLogOn"]), "allowWebClientLogOn": milestoneBoolValue(role.Data["allowWebClientLogOn"]), "description": milestoneStringValue(role.Data["description"]), "displayName": roleDisplayName, "dualAuthorizationRequired": milestoneBoolValue(role.Data["dualAuthorizationRequired"]), "makeUsersAnonymousDuringPTZSession": milestoneBoolValue(role.Data["makeUsersAnonymousDuringPTZSession"]), "name": roleName, "relations": relations, "users": users, } _, _, err := operationMilestoneRequestJSON( ctx, config, http.MethodPut, "/API/rest/v1/roles/"+url.PathEscape(role.ID), payload, ) return err } func patchOperationMilestoneRole( ctx context.Context, config milestoneRuntimeConfig, roleID string, payload map[string]any, ) error { _, _, err := operationMilestoneRequestJSON( ctx, config, http.MethodPatch, "/API/rest/v1/roles/"+url.PathEscape(roleID), payload, ) return err } func listOperationMilestoneObjectsAtPath( ctx context.Context, config milestoneRuntimeConfig, path string, ) ([]map[string]any, error) { body, _, err := operationMilestoneRequestJSON(ctx, config, http.MethodGet, path, nil) if err != nil { return nil, err } return decodeMilestoneObjectItems(body) } func operationMilestoneRequestJSON( ctx context.Context, config milestoneRuntimeConfig, method string, path string, payload any, ) ([]byte, int, error) { var bodyReader io.Reader if payload != nil { body, err := json.Marshal(payload) if err != nil { return nil, 0, err } bodyReader = bytes.NewReader(body) } requestURL := strings.TrimRight(config.Host, "/") + path request, err := http.NewRequestWithContext(ctx, method, requestURL, bodyReader) if err != nil { return nil, 0, err } setMilestoneRequestHeaders(request, config, payload != nil) response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return nil, 0, err } defer response.Body.Close() responseBody, readErr := io.ReadAll(response.Body) if readErr != nil { return nil, response.StatusCode, readErr } if response.StatusCode < 200 || response.StatusCode >= 300 { return responseBody, response.StatusCode, fmt.Errorf( "milestone %s %s failed: status=%d body=%s", method, path, response.StatusCode, truncateMilestoneLogBody(responseBody), ) } return responseBody, response.StatusCode, nil } func decodeOperationMilestoneObject(body []byte) map[string]any { var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { return map[string]any{} } if data, ok := decoded["data"].(map[string]any); ok { return data } return decoded } func findOperationMilestoneItemIDByName(items []map[string]any, name string) string { item := findOperationMilestoneItemByName(items, name) if item == nil { return "" } return milestoneItemID(item) } func findOperationMilestoneItemByName(items []map[string]any, name string) map[string]any { normalizedName := normalizeOperationMilestoneName(name) if normalizedName == "" { return nil } for _, item := range items { names := []string{ operationMilestoneItemDisplayName(item), milestoneStringValue(item["name"]), milestoneStringValue(item["userName"]), milestoneStringValue(item["username"]), milestoneStringValue(item["accountName"]), milestoneStringValue(item["loginName"]), } for _, candidate := range names { if normalizeOperationMilestoneName(candidate) == normalizedName { return item } } } return nil } func operationMilestoneItemDisplayName(item map[string]any) string { if value := strings.TrimSpace(milestoneStringValue(item["displayName"])); value != "" { return value } if value := strings.TrimSpace(milestoneStringValue(item["name"])); value != "" { return value } return milestoneItemID(item) } func milestoneItemID(item map[string]any) string { if item == nil { return "" } return milestoneDeviceID(item) } func normalizeOperationMilestoneName(value string) string { return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(value)), " ")) } func operationMilestoneObjectListFromValue(value any) []map[string]any { rawItems, ok := value.([]any) if !ok { return []map[string]any{} } items := make([]map[string]any, 0, len(rawItems)) for _, rawItem := range rawItems { if item, ok := rawItem.(map[string]any); ok { items = append(items, item) } } return items } func appendOperationMilestoneObjectByID( items []map[string]any, nextItem map[string]any, ) []map[string]any { nextID := milestoneItemID(nextItem) if nextID == "" { return items } for _, item := range items { if strings.EqualFold(milestoneItemID(item), nextID) { return items } } return append(items, nextItem) } func appendOperationMilestoneObjectsByID( items []map[string]any, nextItems ...map[string]any, ) []map[string]any { for _, nextItem := range nextItems { items = appendOperationMilestoneObjectByID(items, nextItem) } return items } func milestoneReferenceListFromItems(items []map[string]any) []map[string]any { refs := make([]map[string]any, 0, len(items)) for _, item := range items { if id := milestoneItemID(item); id != "" { refs = appendMilestoneReference(refs, id) } } return refs } func milestoneReferenceListFromValue(value any) []map[string]any { rawItems, ok := value.([]any) if !ok { return []map[string]any{} } refs := make([]map[string]any, 0, len(rawItems)) for _, rawItem := range rawItems { if item, ok := rawItem.(map[string]any); ok { if id := milestoneItemID(item); id != "" { refs = appendMilestoneReference(refs, id) } continue } if id := strings.TrimSpace(milestoneStringValue(rawItem)); id != "" { refs = appendMilestoneReference(refs, id) } } return refs } func appendMilestoneReference(refs []map[string]any, id string) []map[string]any { id = strings.TrimSpace(id) if id == "" { return refs } for _, ref := range refs { if strings.EqualFold(strings.TrimSpace(milestoneStringValue(ref["id"])), id) { return refs } } return append(refs, map[string]any{ "id": id, }) }