teg/backend/milestone_storage.go
2026-06-11 15:02:19 +02:00

3001 lines
79 KiB
Go

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