// backend\milestone_storage.go package main import ( "context" "encoding/json" "fmt" "io" "log" "net/http" "net/url" "strconv" "strings" "time" ) const ( defaultCreateStorageMaxSizeMB = 30 * 1024 defaultCreateStorageRetainMinutes = 60 defaultCreateArchiveMaxSizeMB = 3.6 * 1024 * 1024 defaultCreateArchiveRetainMinutes = 365 * 24 * 60 ) 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"` IsDefault bool `json:"isDefault"` } 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"` IsDefault bool `json:"isDefault"` 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 updateMilestoneStorageRequest struct { DisplayName *string `json:"displayName"` Name *string `json:"name"` 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 float64 `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", response.StatusCode, ) } 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", response.StatusCode, ) } archives, err := decodeMilestoneArchiveStorages(body) if err != nil { return nil, err } if archives == nil { return []milestoneArchiveStorage{}, nil } return archives, nil } func getMilestoneArchiveStorage( ctx context.Context, config milestoneRuntimeConfig, archiveStorageID string, ) (*milestoneArchiveStorage, error) { archiveStorageID = strings.TrimSpace(archiveStorageID) if archiveStorageID == "" { return nil, nil } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/archiveStorages/" + url.PathEscape(archiveStorageID) 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 storage get failed: status=%d body=%s", response.StatusCode, string(body), ) } return decodeMilestoneArchiveStorage(body) } 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", response.StatusCode, ) } 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 getMilestoneRecordingServerDefaultStorageID( ctx context.Context, config milestoneRuntimeConfig, ) string { recordingServerID := strings.TrimSpace(config.ServerID) if recordingServerID == "" { return "" } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/recordingServers/" + url.PathEscape(recordingServerID) request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return "" } setMilestoneRequestHeaders(request, config, false) response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return "" } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil || response.StatusCode < 200 || response.StatusCode >= 300 { return "" } var envelope struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(body, &envelope); err != nil || envelope.Data == nil { return "" } // Check direct fields (object reference or plain string ID) for _, key := range []string{"storage", "defaultStorage", "defaultRecordingStorage"} { switch v := envelope.Data[key].(type) { case map[string]any: if id := strings.TrimSpace(milestoneStringValue(v["id"])); id != "" { return id } case string: if id := strings.TrimSpace(v); id != "" { return id } } } // Check inside relations if relations, ok := envelope.Data["relations"].(map[string]any); ok { for _, key := range []string{"storage", "defaultStorage", "defaultRecordingStorage"} { if ref, ok := relations[key].(map[string]any); ok { if id := strings.TrimSpace(milestoneStringValue(ref["id"])); id != "" { return id } } } } // Log the recording server response keys to help identify the correct field keys := make([]string, 0, len(envelope.Data)) for k := range envelope.Data { keys = append(keys, k) } log.Printf("Milestone recording server response keys (default storage not found): %v", keys) return "" } func getMilestoneStoragesWithArchives( ctx context.Context, config milestoneRuntimeConfig, ) ([]milestoneStorageWithArchives, error) { storages, err := getMilestoneStorages(ctx, config) if err != nil { return nil, err } // Prefer isDefault flag returned directly in the storage list defaultStorageID := "" for _, s := range storages { if s.IsDefault { defaultStorageID = s.ID break } } // Fall back to recording server endpoint if defaultStorageID == "" { defaultStorageID = getMilestoneRecordingServerDefaultStorageID(ctx, config) if defaultStorageID != "" { log.Printf("Milestone default storage from recording server: id=%s", defaultStorageID) } else { log.Printf("Milestone default storage could not be determined (storages=%d)", len(storages)) } } 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, IsDefault: defaultStorageID != "" && storage.ID == defaultStorageID, 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 name == "" { name = displayName } if displayName == "" { displayName = name } diskPath = normalizeMilestoneCreateDiskPath(diskPath, name, displayName) if name == "" { writeError(w, http.StatusBadRequest, "Name fehlt") return } if diskPath == "" { writeError(w, http.StatusBadRequest, "Speicherpfad fehlt") return } maxSize := input.MaxSize if maxSize < 0 { maxSize = defaultCreateStorageMaxSizeMB } retainMinutes := input.RetainMinutes if retainMinutes <= 0 { retainMinutes = defaultCreateStorageRetainMinutes } 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": maxSize, "retainMinutes": retainMinutes, "signing": input.Signing, } 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) } archiveDiskPath = normalizeMilestoneCreateDiskPath( archiveDiskPath, archiveFolderName, name, displayName, ) if archiveDiskPath == "" { writeError(w, http.StatusBadRequest, "Archivpfad fehlt") return } archiveDisplayName := strings.TrimSpace(input.ArchiveDisplayName) archiveName := strings.TrimSpace(input.ArchiveName) if archiveName == "" { archiveName = archiveDisplayName } if archiveDisplayName == "" { archiveDisplayName = archiveName } if archiveName == "" { archiveName = formatArchiveName(storage.Name, storage.ID) } if archiveDisplayName == "" { archiveDisplayName = formatArchiveDisplayName(storage.DisplayName, storage.Name, storage.ID) } archiveMaxSize := input.ArchiveMaxSize if archiveMaxSize < 0 { archiveMaxSize = defaultCreateArchiveMaxSizeMB } archiveRetainMinutes := input.ArchiveRetainMinutes if archiveRetainMinutes <= 0 { archiveRetainMinutes = defaultCreateArchiveRetainMinutes } archivePayload := map[string]any{ "displayName": archiveDisplayName, "name": archiveName, "description": strings.TrimSpace(input.ArchiveDescription), "diskPath": archiveDiskPath, "maxSize": archiveMaxSize, "retainMinutes": 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 } currentArchiveStorage, err := getMilestoneArchiveStorage(r.Context(), config, archiveStorageID) if err != nil { log.Printf("Milestone archive storage load before update failed: archiveStorageId=%s error=%v", archiveStorageID, err) writeError(w, http.StatusBadGateway, "Archive-Storage konnte nicht geladen werden") return } payload := map[string]any{} var folderRename milestoneFolderRenameResult if input.Name != nil && input.DiskPath == nil && currentArchiveStorage != nil { nextName := strings.TrimSpace(*input.Name) if nextName != "" && !strings.EqualFold(strings.TrimSpace(currentArchiveStorage.Name), nextName) { folderRename, err = s.renameMilestoneStorageFolderIfNeeded( r.Context(), "archive", currentArchiveStorage.DiskPath, currentArchiveStorage.Name, currentArchiveStorage.DisplayName, nextName, ) if err != nil { log.Printf("Milestone archive folder rename failed: archiveStorageId=%s error=%v", archiveStorageID, err) writeError(w, http.StatusBadGateway, "Archiv-Ordner konnte nicht umbenannt werden") return } if folderRename.Renamed { payload["diskPath"] = folderRename.NewPath } } } 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 { s.rollbackMilestoneFolderRename(r.Context(), "archive", folderRename) 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, "renamedPath": folderRename.NewPath, }) } func patchMilestoneStorage( ctx context.Context, config milestoneRuntimeConfig, storageID string, payload map[string]any, ) error { body, err := json.Marshal(payload) if err != nil { return err } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/storages/" + url.PathEscape(storageID) 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 storage patch failed: status=%d body=%s", response.StatusCode, string(responseBody), ) } return nil } func (s *Server) handleUpdateMilestoneStorage(w http.ResponseWriter, r *http.Request) { storageID := strings.TrimSpace(r.PathValue("id")) if storageID == "" { writeError(w, http.StatusBadRequest, "Storage-ID fehlt") return } var input updateMilestoneStorageRequest 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 } currentStorage, err := getMilestoneStorage(r.Context(), config, storageID) if err != nil { log.Printf("Milestone storage load before update failed: storageId=%s error=%v", storageID, err) writeError(w, http.StatusBadGateway, "Speicher konnte nicht geladen werden") return } payload := map[string]any{} var folderRename milestoneFolderRenameResult if input.Name != nil && currentStorage != nil { nextName := strings.TrimSpace(*input.Name) if nextName != "" && !strings.EqualFold(strings.TrimSpace(currentStorage.Name), nextName) { folderRename, err = s.renameMilestoneStorageFolderIfNeeded( r.Context(), "storage", currentStorage.DiskPath, currentStorage.Name, currentStorage.DisplayName, nextName, ) if err != nil { log.Printf("Milestone storage folder rename failed: storageId=%s error=%v", storageID, err) writeError(w, http.StatusBadGateway, "Speicher-Ordner konnte nicht umbenannt werden") return } if folderRename.Renamed { payload["diskPath"] = folderRename.NewPath } } } if input.DisplayName != nil { payload["displayName"] = strings.TrimSpace(*input.DisplayName) } if input.Name != nil { payload["name"] = strings.TrimSpace(*input.Name) } 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 := patchMilestoneStorage(r.Context(), config, storageID, payload); err != nil { s.rollbackMilestoneFolderRename(r.Context(), "storage", folderRename) log.Printf("Milestone storage update failed: storageId=%s error=%v", storageID, err) writeError(w, http.StatusBadGateway, "Speicher konnte nicht aktualisiert werden") return } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "renamedPath": folderRename.NewPath, }) } 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", response.StatusCode, ) } 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") } } type milestoneStorageDeviceBasicInfo struct { ID string ResourceType string DisplayName string Name string RecordingStorageID string } func milestoneStringValue(value any) string { if value == nil { return "" } if stringValue, ok := value.(string); ok { return stringValue } return fmt.Sprint(value) } func milestoneObjectID(value any) string { if objectValue, ok := value.(map[string]any); ok { return strings.TrimSpace(milestoneStringValue(objectValue["id"])) } return strings.TrimSpace(milestoneStringValue(value)) } func milestoneDeviceID(item map[string]any) string { if id := strings.TrimSpace(milestoneStringValue(item["id"])); id != "" { return id } if relations, ok := item["relations"].(map[string]any); ok { if self, ok := relations["self"].(map[string]any); ok { if id := strings.TrimSpace(milestoneStringValue(self["id"])); id != "" { return id } } } return "" } func milestoneDeviceRecordingStorageID(item map[string]any) string { if id := milestoneObjectID(item["recordingStorage"]); id != "" { return id } if relations, ok := item["relations"].(map[string]any); ok { if id := milestoneObjectID(relations["recordingStorage"]); id != "" { return id } } return "" } func milestoneDeviceHardwareID(item map[string]any) string { if relations, ok := item["relations"].(map[string]any); ok { if parent, ok := relations["parent"].(map[string]any); ok { parentType := strings.TrimSpace(milestoneStringValue(parent["type"])) if parentType == "" || strings.EqualFold(parentType, "hardware") { return strings.TrimSpace(milestoneStringValue(parent["id"])) } } if id := milestoneObjectID(relations["hardware"]); id != "" { return id } } return "" } func milestoneDeviceDisplayName(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 milestoneDeviceID(item) } func decodeMilestoneObjectItems(body []byte) ([]map[string]any, 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 dataArray, ok := decoded["data"].([]any); ok { rawItems = dataArray } else if data, ok := decoded["data"].(map[string]any); ok { if array, ok := data["array"].([]any); ok { rawItems = array } } 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, nil } func decodeMilestoneObject(body []byte) (map[string]any, error) { var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { return nil, err } if data, ok := decoded["data"].(map[string]any); ok { return data, nil } return decoded, nil } func getMilestoneDevicesByStorage( ctx context.Context, config milestoneRuntimeConfig, resourceType string, storageID string, ) ([]milestoneStorageDeviceBasicInfo, error) { storageID = strings.TrimSpace(storageID) if storageID == "" { return []milestoneStorageDeviceBasicInfo{}, nil } requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/" + strings.Trim(resourceType, "/") + "?disabled" 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 %s get failed: status=%d body=%s", resourceType, response.StatusCode, string(body), ) } items, err := decodeMilestoneObjectItems(body) if err != nil { return nil, err } devices := make([]milestoneStorageDeviceBasicInfo, 0) for _, item := range items { id := milestoneDeviceID(item) if id == "" { continue } recordingStorageID := milestoneDeviceRecordingStorageID(item) if !strings.EqualFold(recordingStorageID, storageID) { continue } devices = append(devices, milestoneStorageDeviceBasicInfo{ ID: id, ResourceType: resourceType, DisplayName: milestoneDeviceDisplayName(item), Name: strings.TrimSpace(milestoneStringValue(item["name"])), RecordingStorageID: recordingStorageID, }) } return devices, nil } type milestoneStorageDeleteProgressReporter func(event string, payload map[string]any) type milestoneTaskResult struct { Progress string `json:"progress"` ErrorCode string `json:"errorCode"` ErrorText string `json:"errorText"` State string `json:"state"` Path string `json:"path"` TaskType string `json:"taskType"` } func postMilestoneChangeDeviceRecordingStorageTask( ctx context.Context, config milestoneRuntimeConfig, resourceType string, deviceID string, targetStorage milestoneStorage, report milestoneStorageDeleteProgressReporter, progressStart int, progressEnd int, ) error { resourceType = strings.Trim(strings.TrimSpace(resourceType), "/") deviceID = strings.TrimSpace(deviceID) targetStorageID := strings.TrimSpace(targetStorage.ID) if resourceType == "" { return fmt.Errorf("resource type is missing") } if deviceID == "" { return fmt.Errorf("device id is missing") } if targetStorageID == "" { return fmt.Errorf("target storage id is missing") } endpoint := strings.TrimRight(config.Host, "/") + "/API/rest/v1/" + resourceType + "/" + url.PathEscape(deviceID) + "?task=ChangeDeviceRecordingStorage" payload := map[string]any{ "itemSelection": map[string]any{ "type": "storages", "id": targetStorageID, }, "moveData": true, } responseBody, err := postMilestoneTask(ctx, config, endpoint, payload) if err != nil { log.Printf( "Milestone ChangeDeviceRecordingStorage task failed: resourceType=%s deviceId=%s targetStorageId=%s targetStorageName=%s endpoint=%s payload=%s error=%v", resourceType, deviceID, targetStorageID, formatStorageTitleForLog(targetStorage), endpoint, milestonePayloadForLog(payload), err, ) return fmt.Errorf( "milestone ChangeDeviceRecordingStorage task failed for %s id=%s: endpoint=%s payload=%s error=%w", resourceType, deviceID, endpoint, milestonePayloadForLog(payload), err, ) } log.Printf( "Milestone ChangeDeviceRecordingStorage task started: resourceType=%s deviceId=%s targetStorageId=%s targetStorageName=%s endpoint=%s payload=%s response=%s", resourceType, deviceID, targetStorageID, formatStorageTitleForLog(targetStorage), endpoint, milestonePayloadForLog(payload), string(responseBody), ) if result, ok := milestoneTaskResultFromBody(responseBody); ok { reportMilestoneTaskResult(report, progressStart, progressEnd, result) if milestoneTaskResultIsError(result) { return fmt.Errorf( "milestone ChangeDeviceRecordingStorage task returned error state=%s errorCode=%s errorText=%s", result.State, result.ErrorCode, result.ErrorText, ) } if milestoneTaskResultIsComplete(result) { return nil } } taskID := milestoneTaskIDFromBody(responseBody) if taskID == "" { if report != nil { report("storage-delete-progress", map[string]any{ "progress": progressEnd, "message": "Milestone hat keinen Task-ID zurückgegeben. Der ChangeDeviceRecordingStorage-Aufruf wurde angenommen.", }) } return nil } if report != nil { report("storage-delete-progress", map[string]any{ "progress": progressStart, "message": "ChangeDeviceRecordingStorage-Task gestartet.", "taskId": taskID, }) } return pollMilestoneTaskProgress( ctx, config, taskID, report, progressStart, progressEnd, ) } func postMilestoneTask( ctx context.Context, config milestoneRuntimeConfig, requestURL string, payload map[string]any, ) ([]byte, error) { body, err := json.Marshal(payload) if err != nil { return nil, err } 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, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { return responseBody, fmt.Errorf( "status=%d body=%s", response.StatusCode, string(responseBody), ) } return responseBody, nil } func milestonePayloadForLog(payload map[string]any) string { body, err := json.Marshal(payload) if err != nil { return fmt.Sprintf("%v", payload) } return string(body) } func milestoneTaskIDFromBody(body []byte) string { var decoded any if err := json.Unmarshal(body, &decoded); err != nil { return "" } return firstMilestoneStringByKeys(decoded, []string{ "taskId", "taskID", "id", }) } func firstMilestoneStringByKeys(value any, keys []string) string { object, ok := value.(map[string]any) if !ok { if values, ok := value.([]any); ok { for _, item := range values { if result := firstMilestoneStringByKeys(item, keys); result != "" { return result } } } return "" } for _, key := range keys { if stringValue, ok := object[key].(string); ok && strings.TrimSpace(stringValue) != "" { return strings.TrimSpace(stringValue) } } if relations, ok := object["relations"].(map[string]any); ok { if self, ok := relations["self"].(map[string]any); ok { if id := strings.TrimSpace(milestoneStringValue(self["id"])); id != "" { return id } } } for _, nested := range object { if result := firstMilestoneStringByKeys(nested, keys); result != "" { return result } } return "" } func milestoneTaskResultFromBody(body []byte) (milestoneTaskResult, bool) { var decoded map[string]any if err := json.Unmarshal(body, &decoded); err != nil { return milestoneTaskResult{}, false } if result, ok := milestoneTaskResultFromValue(decoded["result"]); ok { return result, true } if data, ok := decoded["data"].(map[string]any); ok { if result, ok := milestoneTaskResultFromValue(data["result"]); ok { return result, true } } return milestoneTaskResult{}, false } func milestoneTaskResultFromValue(value any) (milestoneTaskResult, bool) { object, ok := value.(map[string]any) if !ok { return milestoneTaskResult{}, false } result := milestoneTaskResult{ Progress: strings.TrimSpace(milestoneStringValue(object["progress"])), ErrorCode: strings.TrimSpace(milestoneStringValue(object["errorCode"])), ErrorText: strings.TrimSpace(milestoneStringValue(object["errorText"])), State: strings.TrimSpace(milestoneStringValue(object["state"])), Path: strings.TrimSpace(milestoneStringValue(object["path"])), TaskType: strings.TrimSpace(milestoneStringValue(object["taskType"])), } return result, result.Progress != "" || result.ErrorCode != "" || result.ErrorText != "" || result.State != "" || result.Path != "" || result.TaskType != "" } func milestoneTaskResultProgress(result milestoneTaskResult, start int, end int) int { if result.Progress == "" { return end } progress, err := strconv.Atoi(result.Progress) if err != nil { return end } if progress < 0 { progress = 0 } if progress > 100 { progress = 100 } if end < start { return progress } return start + ((end - start) * progress / 100) } func milestoneTaskResultIsComplete(result milestoneTaskResult) bool { state := strings.TrimSpace(strings.ToLower(result.State)) return state == "success" || state == "completed" || state == "complete" || state == "finished" } func milestoneTaskResultIsError(result milestoneTaskResult) bool { state := strings.TrimSpace(strings.ToLower(result.State)) return state == "error" || state == "failed" || state == "failure" || state == "faulted" } func reportMilestoneTaskResult( report milestoneStorageDeleteProgressReporter, start int, end int, result milestoneTaskResult, ) { if report == nil { return } message := "Milestone-Task läuft." if result.State != "" { message = "Milestone-Task Status: " + result.State } if result.ErrorText != "" { message = result.ErrorText } report("storage-delete-progress", map[string]any{ "progress": milestoneTaskResultProgress(result, start, end), "message": message, "taskState": result.State, "taskType": result.TaskType, "taskPath": result.Path, "errorCode": result.ErrorCode, "errorText": result.ErrorText, }) } func getMilestoneTaskResult( ctx context.Context, config milestoneRuntimeConfig, taskID string, ) (milestoneTaskResult, bool, error) { taskID = strings.TrimSpace(taskID) if taskID == "" { return milestoneTaskResult{}, false, fmt.Errorf("task id is missing") } requestURLs := []string{ strings.TrimRight(config.Host, "/") + "/API/rest/v1/tasks/" + url.PathEscape(taskID), } recordingServerID := strings.TrimSpace(config.ServerID) if recordingServerID != "" { requestURLs = append( requestURLs, strings.TrimRight(config.Host, "/")+ "/API/rest/v1/recordingServers/"+ url.PathEscape(recordingServerID)+ "/tasks/"+ url.PathEscape(taskID), ) } var lastErr error for _, requestURL := range requestURLs { request, err := http.NewRequestWithContext(ctx, http.MethodGet, requestURL, nil) if err != nil { return milestoneTaskResult{}, false, err } setMilestoneRequestHeaders(request, config, false) response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { lastErr = err continue } body, readErr := io.ReadAll(response.Body) response.Body.Close() if readErr != nil { return milestoneTaskResult{}, false, readErr } if response.StatusCode < 200 || response.StatusCode >= 300 { lastErr = fmt.Errorf( "milestone task get failed: url=%s status=%d body=%s", requestURL, response.StatusCode, string(body), ) continue } result, ok := milestoneTaskResultFromBody(body) return result, ok, nil } if lastErr != nil { return milestoneTaskResult{}, false, lastErr } return milestoneTaskResult{}, false, nil } func pollMilestoneTaskProgress( ctx context.Context, config milestoneRuntimeConfig, taskID string, report milestoneStorageDeleteProgressReporter, progressStart int, progressEnd int, ) error { deadline := time.NewTimer(5 * time.Minute) defer deadline.Stop() ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() for { result, ok, err := getMilestoneTaskResult(ctx, config, taskID) if err != nil { return err } if ok { reportMilestoneTaskResult(report, progressStart, progressEnd, result) if milestoneTaskResultIsError(result) { return fmt.Errorf( "milestone task %s failed: state=%s errorCode=%s errorText=%s", taskID, result.State, result.ErrorCode, result.ErrorText, ) } if milestoneTaskResultIsComplete(result) { return nil } } select { case <-ctx.Done(): return ctx.Err() case <-deadline.C: return fmt.Errorf("milestone task %s progress polling timed out", taskID) case <-ticker.C: } } } func writeMilestoneStorageSSE( w http.ResponseWriter, flusher http.Flusher, event string, payload map[string]any, ) { if payload == nil { payload = map[string]any{} } body, err := json.Marshal(payload) if err != nil { body = []byte(`{"message":"SSE payload konnte nicht serialisiert werden"}`) } fmt.Fprintf(w, "event: %s\n", event) fmt.Fprintf(w, "data: %s\n\n", body) flusher.Flush() } type milestoneStorageDeviceMigrationResult struct { Cameras int `json:"cameras"` Microphones int `json:"microphones"` } func migrateMilestoneStorageDevicesToDefaultStorage( ctx context.Context, config milestoneRuntimeConfig, sourceStorageID string, targetStorage milestoneStorage, report milestoneStorageDeleteProgressReporter, ) (milestoneStorageDeviceMigrationResult, error) { result := milestoneStorageDeviceMigrationResult{} targetStorageID := strings.TrimSpace(targetStorage.ID) if targetStorageID == "" { return result, fmt.Errorf("default storage id is missing") } cameras, err := getMilestoneDevicesByStorage(ctx, config, "cameras", sourceStorageID) if err != nil { return result, err } microphones, err := getMilestoneDevicesByStorage(ctx, config, "microphones", sourceStorageID) if err != nil { return result, err } totalDevices := len(cameras) + len(microphones) if report != nil { report("storage-delete-progress", map[string]any{ "progress": 5, "message": fmt.Sprintf("%d Kamera(s) und %d Mikrofon(e) werden auf %s umgestellt.", len(cameras), len(microphones), formatStorageTitleForLog(targetStorage)), "cameras": len(cameras), "microphones": len(microphones), "defaultStorageId": targetStorageID, }) } completedDevices := 0 nextProgressRange := func() (int, int) { if totalDevices <= 0 { return 80, 90 } start := 10 + (completedDevices * 80 / totalDevices) end := 10 + ((completedDevices + 1) * 80 / totalDevices) return start, end } for _, camera := range cameras { progressStart, progressEnd := nextProgressRange() if report != nil { report("storage-delete-progress", map[string]any{ "progress": progressStart, "message": fmt.Sprintf("Kamera %q wird auf den Standardspeicher umgestellt.", camera.DisplayName), "deviceType": "camera", "deviceId": camera.ID, "deviceName": camera.DisplayName, }) } if err := postMilestoneChangeDeviceRecordingStorageTask( ctx, config, "cameras", camera.ID, targetStorage, report, progressStart, progressEnd, ); err != nil { return result, fmt.Errorf( "Kamera %q konnte nicht per ChangeDeviceRecordingStorage auf den Standardspeicher %q umgestellt werden: %w", camera.DisplayName, formatStorageTitleForLog(targetStorage), err, ) } completedDevices++ result.Cameras++ if report != nil { report("storage-delete-progress", map[string]any{ "progress": progressEnd, "message": fmt.Sprintf("Kamera %q wurde umgestellt.", camera.DisplayName), "deviceType": "camera", "deviceId": camera.ID, "deviceName": camera.DisplayName, }) } } for _, microphone := range microphones { progressStart, progressEnd := nextProgressRange() if report != nil { report("storage-delete-progress", map[string]any{ "progress": progressStart, "message": fmt.Sprintf("Mikrofon %q wird auf den Standardspeicher umgestellt.", microphone.DisplayName), "deviceType": "microphone", "deviceId": microphone.ID, "deviceName": microphone.DisplayName, }) } if err := postMilestoneChangeDeviceRecordingStorageTask( ctx, config, "microphones", microphone.ID, targetStorage, report, progressStart, progressEnd, ); err != nil { return result, fmt.Errorf( "Mikrofon %q konnte nicht per ChangeDeviceRecordingStorage auf den Standardspeicher %q umgestellt werden: %w", microphone.DisplayName, formatStorageTitleForLog(targetStorage), err, ) } completedDevices++ result.Microphones++ if report != nil { report("storage-delete-progress", map[string]any{ "progress": progressEnd, "message": fmt.Sprintf("Mikrofon %q wurde umgestellt.", microphone.DisplayName), "deviceType": "microphone", "deviceId": microphone.ID, "deviceName": microphone.DisplayName, }) } } return result, nil } func formatStorageTitleForLog(storage milestoneStorage) string { for _, value := range []string{storage.DisplayName, storage.Name, storage.ID} { if strings.TrimSpace(value) != "" { return strings.TrimSpace(value) } } return "Standardspeicher" } func findMilestoneStorageByID(storages []milestoneStorage, storageID string) *milestoneStorage { for index := range storages { if strings.EqualFold(storages[index].ID, storageID) { return &storages[index] } } return nil } func storageLooksLikeLocalDefault(storage milestoneStorage) bool { for _, value := range []string{storage.DisplayName, storage.Name} { if strings.EqualFold(strings.TrimSpace(value), "Local storage") { return true } } return false } func findMilestoneDefaultStorageForDelete( ctx context.Context, config milestoneRuntimeConfig, storages []milestoneStorage, sourceStorageID string, ) (*milestoneStorage, error) { for index := range storages { if storages[index].IsDefault { if strings.EqualFold(storages[index].ID, sourceStorageID) { return nil, fmt.Errorf("Der Standardspeicher kann nicht gelöscht werden") } return &storages[index], nil } } defaultStorageID := getMilestoneRecordingServerDefaultStorageID(ctx, config) if defaultStorageID != "" { if strings.EqualFold(defaultStorageID, sourceStorageID) { return nil, fmt.Errorf("Der Standardspeicher kann nicht gelöscht werden") } if storage := findMilestoneStorageByID(storages, defaultStorageID); storage != nil { return storage, nil } return &milestoneStorage{ ID: defaultStorageID, DisplayName: "Standardspeicher", Name: "Standardspeicher", }, nil } for index := range storages { if !strings.EqualFold(storages[index].ID, sourceStorageID) && storageLooksLikeLocalDefault(storages[index]) { return &storages[index], nil } } return nil, fmt.Errorf("Standardspeicher \"Local storage\" wurde nicht gefunden") } type milestoneFolderRenameResult struct { OldPath string NewPath string Renamed bool } func milestoneDiskPathBaseName(pathValue string) string { pathValue = strings.TrimSpace(pathValue) pathValue = strings.TrimRight(pathValue, `\/`) if pathValue == "" { return "" } separatorIndex := strings.LastIndexAny(pathValue, `\/`) if separatorIndex < 0 { return pathValue } return pathValue[separatorIndex+1:] } func milestoneDiskPathParent(pathValue string) string { pathValue = strings.TrimSpace(pathValue) pathValue = strings.TrimRight(pathValue, `\/`) if pathValue == "" { return "" } separatorIndex := strings.LastIndexAny(pathValue, `\/`) if separatorIndex < 0 { return "" } return pathValue[:separatorIndex] } func normalizeMilestoneCreateDiskPath(pathValue string, names ...string) string { pathValue = strings.TrimSpace(pathValue) if pathValue == "" { return "" } if milestoneFolderNameMatches(pathValue, names...) { parentPath := milestoneDiskPathParent(pathValue) if parentPath != "" { return parentPath } } return pathValue } type milestoneFolderDeleteCandidate struct { RootType string Path string Label string } func appendMilestoneFolderDeleteCandidate( candidates []milestoneFolderDeleteCandidate, rootType string, pathValue string, label string, ) []milestoneFolderDeleteCandidate { pathValue = strings.TrimSpace(pathValue) if pathValue == "" { return candidates } normalizedPath := strings.ToLower(strings.TrimRight(pathValue, `\/`)) for _, candidate := range candidates { if strings.ToLower(strings.TrimRight(candidate.Path, `\/`)) == normalizedPath { return candidates } } return append(candidates, milestoneFolderDeleteCandidate{ RootType: rootType, Path: pathValue, Label: label, }) } func milestoneStorageFolderDeleteCandidates(storage milestoneStorage) []milestoneFolderDeleteCandidate { candidates := []milestoneFolderDeleteCandidate{} diskPath := strings.TrimSpace(storage.DiskPath) if diskPath == "" { return candidates } storageID := strings.TrimSpace(storage.ID) if storageID != "" && !strings.EqualFold(milestoneDiskPathBaseName(diskPath), storageID) { candidates = appendMilestoneFolderDeleteCandidate( candidates, "storage", joinMilestoneDiskPath(diskPath, storageID), "Basis-Storage-Datenordner", ) } if strings.EqualFold(milestoneDiskPathBaseName(diskPath), storageID) || milestoneFolderNameMatches(diskPath, storage.Name, storage.DisplayName) { candidates = appendMilestoneFolderDeleteCandidate( candidates, "storage", diskPath, "Basis-Ordner", ) } return candidates } func milestoneArchiveFolderDeleteCandidates(archive milestoneArchiveStorage) []milestoneFolderDeleteCandidate { candidates := []milestoneFolderDeleteCandidate{} diskPath := strings.TrimSpace(archive.DiskPath) if diskPath == "" { return candidates } archiveID := strings.TrimSpace(archive.ID) if archiveID != "" && !strings.EqualFold(milestoneDiskPathBaseName(diskPath), archiveID) { candidates = appendMilestoneFolderDeleteCandidate( candidates, "archive", joinMilestoneDiskPath(diskPath, archiveID), "Archiv-Storage-Datenordner", ) } if strings.EqualFold(milestoneDiskPathBaseName(diskPath), archiveID) || milestoneFolderNameMatches(diskPath, archive.Name, archive.DisplayName) { candidates = appendMilestoneFolderDeleteCandidate( candidates, "archive", diskPath, "Archiv-Ordner", ) } return candidates } func milestoneFolderNameMatches(pathValue string, names ...string) bool { baseName := milestoneDiskPathBaseName(pathValue) if baseName == "" { return false } for _, name := range names { if strings.EqualFold(strings.TrimSpace(name), baseName) { return true } } return false } func canUseMilestoneNameAsFolderName(value string) bool { value = strings.TrimSpace(value) if value == "" || value == "." || value == ".." { return false } return !strings.ContainsAny(value, `<>:"/\|?*`) } func (s *Server) renameMilestoneStorageFolderIfNeeded( ctx context.Context, rootType string, currentDiskPath string, currentName string, currentDisplayName string, nextName string, ) (milestoneFolderRenameResult, error) { result := milestoneFolderRenameResult{ OldPath: strings.TrimSpace(currentDiskPath), } nextName = strings.TrimSpace(nextName) if result.OldPath == "" || nextName == "" { return result, nil } if !canUseMilestoneNameAsFolderName(nextName) { return result, fmt.Errorf("Neuer Ordnername %q ist kein gültiger Windows-Ordnername", nextName) } if strings.EqualFold(milestoneDiskPathBaseName(result.OldPath), nextName) { result.NewPath = result.OldPath return result, nil } if !milestoneFolderNameMatches(result.OldPath, currentName, currentDisplayName) { return result, nil } newPath, err := s.renameServerFolderPath(ctx, rootType, result.OldPath, nextName) if err != nil { return result, err } result.NewPath = newPath result.Renamed = true return result, nil } func (s *Server) rollbackMilestoneFolderRename( ctx context.Context, rootType string, renameResult milestoneFolderRenameResult, ) { if !renameResult.Renamed || strings.TrimSpace(renameResult.OldPath) == "" || strings.TrimSpace(renameResult.NewPath) == "" { return } oldName := milestoneDiskPathBaseName(renameResult.OldPath) if oldName == "" { return } if _, err := s.renameServerFolderPath(ctx, rootType, renameResult.NewPath, oldName); err != nil { log.Printf( "Milestone folder rename rollback failed: rootType=%s newPath=%s oldPath=%s error=%v", rootType, renameResult.NewPath, renameResult.OldPath, err, ) } } func (s *Server) deleteMilestoneStorageDiskFolders( ctx context.Context, storage milestoneStorage, archiveStorages []milestoneArchiveStorage, report milestoneStorageDeleteProgressReporter, ) []string { errors := []string{} candidates := []milestoneFolderDeleteCandidate{} for _, archive := range archiveStorages { candidates = append(candidates, milestoneArchiveFolderDeleteCandidates(archive)...) } candidates = append(candidates, milestoneStorageFolderDeleteCandidates(storage)...) for _, candidate := range candidates { if strings.TrimSpace(candidate.Path) == "" { continue } message := fmt.Sprintf("%s %q wird gelöscht.", candidate.Label, candidate.Path) if report != nil { report("storage-delete-progress", map[string]any{ "progress": 97, "message": message, "folder": candidate.Path, "rootType": candidate.RootType, }) } if err := s.deleteServerFolderPath(ctx, candidate.RootType, candidate.Path); err != nil { log.Printf( "Milestone folder delete failed: rootType=%s label=%s path=%s error=%v", candidate.RootType, candidate.Label, candidate.Path, err, ) errors = append(errors, fmt.Sprintf("%s %s: %v", candidate.Label, candidate.Path, err)) } } return errors } func deleteMilestoneStorageFromAPI( ctx context.Context, config milestoneRuntimeConfig, storageID string, ) error { requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/storages/" + url.PathEscape(storageID) request, err := http.NewRequestWithContext(ctx, http.MethodDelete, requestURL, nil) if err != nil { return err } setMilestoneRequestHeaders(request, config, false) response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return err } defer response.Body.Close() responseBody, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf( "milestone storage delete failed: status=%d body=%s", response.StatusCode, string(responseBody), ) } return nil } func deleteMilestoneArchiveStorageFromAPI( ctx context.Context, config milestoneRuntimeConfig, archiveStorageID string, ) error { requestURL := strings.TrimRight(config.Host, "/") + "/API/rest/v1/archiveStorages/" + url.PathEscape(archiveStorageID) request, err := http.NewRequestWithContext(ctx, http.MethodDelete, requestURL, nil) if err != nil { return err } setMilestoneRequestHeaders(request, config, false) response, err := milestoneHTTPClient(config.SkipTLSVerify).Do(request) if err != nil { return err } defer response.Body.Close() responseBody, _ := io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf( "milestone archive storage delete failed: status=%d body=%s", response.StatusCode, string(responseBody), ) } return nil } func (s *Server) handleDeleteMilestoneStorage(w http.ResponseWriter, r *http.Request) { storageID := strings.TrimSpace(r.PathValue("id")) if storageID == "" { writeError(w, http.StatusBadRequest, "Storage-ID fehlt") return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } storages, err := getMilestoneStorages(r.Context(), config) if err != nil { log.Printf("Milestone storages load failed for delete: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Speicher konnten nicht geladen werden") return } storageToDelete := findMilestoneStorageByID(storages, storageID) if storageToDelete == nil { writeError(w, http.StatusNotFound, "Milestone-Speicher wurde nicht gefunden") return } archiveStoragesToDelete, err := getMilestoneArchiveStorages(r.Context(), config, storageID) if err != nil { log.Printf("Milestone archive storages load before storage delete failed: storageId=%s error=%v", storageID, err) writeError(w, http.StatusBadGateway, "Milestone-Archivspeicher konnten vor dem Löschen nicht geladen werden") return } defaultStorage, err := findMilestoneDefaultStorageForDelete( r.Context(), config, storages, storageID, ) if err != nil { log.Printf("Milestone default storage lookup failed for delete: storageId=%s error=%v", storageID, err) writeError(w, http.StatusBadRequest, err.Error()) return } migration, err := migrateMilestoneStorageDevicesToDefaultStorage( r.Context(), config, storageID, *defaultStorage, nil, ) if err != nil { log.Printf( "Milestone storage device migration failed: storageId=%s defaultStorageId=%s error=%v", storageID, defaultStorage.ID, err, ) writeError(w, http.StatusBadGateway, "Kameras und Mikrofone konnten nicht auf den Standardspeicher umgestellt werden") return } if err := deleteMilestoneStorageFromAPI(r.Context(), config, storageID); err != nil { log.Printf("Milestone storage delete failed: storageId=%s error=%v", storageID, err) writeError(w, http.StatusBadGateway, "Milestone-Speicher konnte nicht gelöscht werden") return } folderDeleteErrors := s.deleteMilestoneStorageDiskFolders( r.Context(), *storageToDelete, archiveStoragesToDelete, nil, ) writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "deletedStorage": map[string]any{ "id": storageToDelete.ID, "displayName": storageToDelete.DisplayName, "name": storageToDelete.Name, }, "defaultStorage": map[string]any{ "id": defaultStorage.ID, "displayName": defaultStorage.DisplayName, "name": defaultStorage.Name, }, "migratedDevices": migration, "folderDeleteErrors": folderDeleteErrors, }) } func (s *Server) handleDeleteMilestoneStorageStream(w http.ResponseWriter, r *http.Request) { flusher, ok := w.(http.Flusher) if !ok { writeError(w, http.StatusInternalServerError, "SSE wird von diesem Server nicht unterstützt") return } w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") report := func(event string, payload map[string]any) { writeMilestoneStorageSSE(w, flusher, event, payload) } fail := func(status int, message string, err error) { payload := map[string]any{ "progress": 100, "message": message, "status": status, } if err != nil { payload["error"] = err.Error() } report("storage-delete-error", payload) } storageID := strings.TrimSpace(r.PathValue("id")) if storageID == "" { fail(http.StatusBadRequest, "Storage-ID fehlt", nil) return } report("storage-delete-progress", map[string]any{ "progress": 0, "message": "Löschvorgang wird vorbereitet.", }) config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { fail(http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden", err) return } storages, err := getMilestoneStorages(r.Context(), config) if err != nil { log.Printf("Milestone storages load failed for delete stream: %v", err) fail(http.StatusBadGateway, "Milestone-Speicher konnten nicht geladen werden", err) return } storageToDelete := findMilestoneStorageByID(storages, storageID) if storageToDelete == nil { fail(http.StatusNotFound, "Milestone-Speicher wurde nicht gefunden", nil) return } archiveStoragesToDelete, err := getMilestoneArchiveStorages(r.Context(), config, storageID) if err != nil { log.Printf("Milestone archive storages load before stream storage delete failed: storageId=%s error=%v", storageID, err) fail(http.StatusBadGateway, "Milestone-Archivspeicher konnten vor dem Löschen nicht geladen werden", err) return } defaultStorage, err := findMilestoneDefaultStorageForDelete( r.Context(), config, storages, storageID, ) if err != nil { log.Printf("Milestone default storage lookup failed for delete stream: storageId=%s error=%v", storageID, err) fail(http.StatusBadRequest, err.Error(), err) return } report("storage-delete-progress", map[string]any{ "progress": 3, "message": fmt.Sprintf("Standardspeicher %q wurde gefunden.", formatStorageTitleForLog(*defaultStorage)), "defaultStorage": map[string]any{ "id": defaultStorage.ID, "displayName": defaultStorage.DisplayName, "name": defaultStorage.Name, }, }) migration, err := migrateMilestoneStorageDevicesToDefaultStorage( r.Context(), config, storageID, *defaultStorage, report, ) if err != nil { log.Printf( "Milestone storage device migration failed during stream delete: storageId=%s defaultStorageId=%s error=%v", storageID, defaultStorage.ID, err, ) fail(http.StatusBadGateway, "Kameras und Mikrofone konnten nicht auf den Standardspeicher umgestellt werden", err) return } report("storage-delete-progress", map[string]any{ "progress": 95, "message": "Basis-Speicher wird gelöscht.", "migratedDevices": migration, }) if err := deleteMilestoneStorageFromAPI(r.Context(), config, storageID); err != nil { log.Printf("Milestone storage delete failed during stream delete: storageId=%s error=%v", storageID, err) fail(http.StatusBadGateway, "Milestone-Speicher konnte nicht gelöscht werden", err) return } folderDeleteErrors := s.deleteMilestoneStorageDiskFolders( r.Context(), *storageToDelete, archiveStoragesToDelete, report, ) completeMessage := "Basis-Speicher wurde gelöscht." if len(folderDeleteErrors) > 0 { completeMessage = "Basis-Speicher wurde gelöscht, aber nicht alle Ordner konnten entfernt werden." } report("storage-delete-complete", map[string]any{ "progress": 100, "message": completeMessage, "deletedStorage": map[string]any{ "id": storageToDelete.ID, "displayName": storageToDelete.DisplayName, "name": storageToDelete.Name, }, "defaultStorage": map[string]any{ "id": defaultStorage.ID, "displayName": defaultStorage.DisplayName, "name": defaultStorage.Name, }, "migratedDevices": migration, "folderDeleteErrors": folderDeleteErrors, }) } func (s *Server) handleDeleteMilestoneArchiveStorage(w http.ResponseWriter, r *http.Request) { archiveStorageID := strings.TrimSpace(r.PathValue("id")) if archiveStorageID == "" { writeError(w, http.StatusBadRequest, "Archive-Storage-ID fehlt") return } config, err := s.getMilestoneRuntimeConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Milestone-Konfiguration konnte nicht geladen werden") return } archiveStorageToDelete, err := getMilestoneArchiveStorage(r.Context(), config, archiveStorageID) if err != nil { log.Printf("Milestone archive storage load before delete failed: archiveStorageId=%s error=%v", archiveStorageID, err) writeError(w, http.StatusBadGateway, "Milestone-Archivspeicher konnte nicht geladen werden") return } if err := deleteMilestoneArchiveStorageFromAPI(r.Context(), config, archiveStorageID); err != nil { log.Printf("Milestone archive storage delete failed: archiveStorageId=%s error=%v", archiveStorageID, err) writeError(w, http.StatusBadGateway, "Milestone-Archivspeicher konnte nicht gelöscht werden") return } folderDeleteErrors := []string{} if archiveStorageToDelete != nil { for _, candidate := range milestoneArchiveFolderDeleteCandidates(*archiveStorageToDelete) { if err := s.deleteServerFolderPath(r.Context(), candidate.RootType, candidate.Path); err != nil { log.Printf( "Milestone archive folder delete failed after archive delete: archiveStorageId=%s path=%s error=%v", archiveStorageID, candidate.Path, err, ) folderDeleteErrors = append(folderDeleteErrors, fmt.Sprintf("%s %s: %v", candidate.Label, candidate.Path, err)) } } } writeJSON(w, http.StatusOK, map[string]any{ "ok": true, "folderDeleteErrors": folderDeleteErrors, }) }