teg/backend/operations_milestone_setup.go
2026-06-26 16:31:26 +02:00

1669 lines
41 KiB
Go

// 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,
})
}