// backend\admin_milestone.go package main import ( "context" "crypto/tls" "encoding/json" "errors" "fmt" "io" "log" "net/http" "net/url" "os" "strings" "time" "github.com/jackc/pgx/v5" ) type milestoneSettingsResponse struct { Host string `json:"host"` Username string `json:"username"` PasswordConfigured bool `json:"passwordConfigured"` SkipTLSVerify bool `json:"skipTlsVerify"` TokenConfigured bool `json:"tokenConfigured"` TokenType string `json:"tokenType"` TokenExpiresAt *time.Time `json:"tokenExpiresAt,omitempty"` TokenUpdatedAt *time.Time `json:"tokenUpdatedAt,omitempty"` ServerID string `json:"serverId"` ServerName string `json:"serverName"` ServerVersion string `json:"serverVersion"` ServerUpdatedAt *time.Time `json:"serverUpdatedAt,omitempty"` ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` ServerFoldersAgentTokenConfigured bool `json:"serverFoldersAgentTokenConfigured"` ServerFoldersStorageRootLabel string `json:"serverFoldersStorageRootLabel"` ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"` ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` UpdatedAt *time.Time `json:"updatedAt,omitempty"` } type updateMilestoneSettingsRequest struct { Host string `json:"host"` Username string `json:"username"` Password string `json:"password"` SkipTLSVerify bool `json:"skipTlsVerify"` ServerFoldersAgentScheme string `json:"serverFoldersAgentScheme"` ServerFoldersAgentPort string `json:"serverFoldersAgentPort"` ServerFoldersStorageRootLabel string `json:"serverFoldersStorageRootLabel"` ServerFoldersStorageRootPath string `json:"serverFoldersStorageRootPath"` ServerFoldersArchiveRootLabel string `json:"serverFoldersArchiveRootLabel"` ServerFoldersArchiveRootPath string `json:"serverFoldersArchiveRootPath"` } type milestoneCredentials struct { Host string Username string Password string SkipTLSVerify bool } type milestoneTokenResponse struct { AccessToken string `json:"access_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } func (s *Server) handleGetMilestoneSettings(w http.ResponseWriter, r *http.Request) { settings, err := s.getMilestoneSettings(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeJSON(w, http.StatusOK, map[string]any{ "settings": milestoneSettingsResponse{ Host: "", Username: "", PasswordConfigured: false, SkipTLSVerify: false, TokenConfigured: false, ServerFoldersAgentScheme: "http", ServerFoldersAgentPort: "8099", ServerFoldersAgentTokenConfigured: false, ServerFoldersStorageRootLabel: "Storage D", ServerFoldersStorageRootPath: "D:/Milestone-Speicher", ServerFoldersArchiveRootLabel: "Archive E", ServerFoldersArchiveRootPath: "E:/", }, }) return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "settings": settings, }) } func (s *Server) handleUpdateMilestoneSettings(w http.ResponseWriter, r *http.Request) { var input updateMilestoneSettingsRequest if err := readJSON(r, &input); err != nil { writeError(w, http.StatusBadRequest, "Invalid JSON") return } host := normalizeMilestoneHost(input.Host) username := strings.TrimSpace(input.Username) password := strings.TrimSpace(input.Password) skipTLSVerify := input.SkipTLSVerify serverFoldersAgentScheme, serverFoldersAgentPort := normalizeServerFoldersAgentSettings( input.ServerFoldersAgentScheme, input.ServerFoldersAgentPort, ) serverFoldersStorageRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersStorageRootLabel, "Storage D") serverFoldersStorageRootPath := normalizeServerFoldersRootPath(input.ServerFoldersStorageRootPath, "D:/Milestone-Speicher") serverFoldersArchiveRootLabel := normalizeServerFoldersRootLabel(input.ServerFoldersArchiveRootLabel, "Archive E") serverFoldersArchiveRootPath := normalizeServerFoldersRootPath(input.ServerFoldersArchiveRootPath, "E:/") if host == "" { writeError(w, http.StatusBadRequest, "IP-Adresse oder Hostname ist erforderlich") return } if username == "" { writeError(w, http.StatusBadRequest, "Benutzername ist erforderlich") return } currentSettings, err := s.getMilestoneSettings(r.Context()) passwordAlreadyConfigured := false if err == nil { passwordAlreadyConfigured = currentSettings.PasswordConfigured } else if !errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht geprüft werden") return } if password == "" && !passwordAlreadyConfigured { writeError(w, http.StatusBadRequest, "Kennwort ist erforderlich") return } encryptionKey := s.milestoneEncryptionKey() if password != "" { _, err = s.db.Exec( r.Context(), ` INSERT INTO milestone_settings ( id, host, username, password_encrypted, skip_tls_verify, access_token_encrypted, token_type, token_expires_at, token_updated_at, server_folders_agent_scheme, server_folders_agent_port, server_folders_storage_root_label, server_folders_storage_root_path, server_folders_archive_root_label, server_folders_archive_root_path, server_folders_agent_token_encrypted ) VALUES ( 1, $1, $2, pgp_sym_encrypt($3, $4), $5, NULL, '', NULL, NULL, $6, $7, $8, $9, $10, $11, NULL ) ON CONFLICT (id) DO UPDATE SET host = EXCLUDED.host, username = EXCLUDED.username, password_encrypted = EXCLUDED.password_encrypted, skip_tls_verify = EXCLUDED.skip_tls_verify, access_token_encrypted = NULL, token_type = '', token_expires_at = NULL, token_updated_at = NULL, server_folders_agent_scheme = EXCLUDED.server_folders_agent_scheme, server_folders_agent_port = EXCLUDED.server_folders_agent_port, server_folders_storage_root_label = EXCLUDED.server_folders_storage_root_label, server_folders_storage_root_path = EXCLUDED.server_folders_storage_root_path, server_folders_archive_root_label = EXCLUDED.server_folders_archive_root_label, server_folders_archive_root_path = EXCLUDED.server_folders_archive_root_path, updated_at = now() `, host, username, password, encryptionKey, skipTLSVerify, serverFoldersAgentScheme, serverFoldersAgentPort, serverFoldersStorageRootLabel, serverFoldersStorageRootPath, serverFoldersArchiveRootLabel, serverFoldersArchiveRootPath, ) } else { _, err = s.db.Exec( r.Context(), ` UPDATE milestone_settings SET host = $1, username = $2, skip_tls_verify = $3, access_token_encrypted = NULL, token_type = '', token_expires_at = NULL, token_updated_at = NULL, server_folders_agent_scheme = $4, server_folders_agent_port = $5, server_folders_storage_root_label = $6, server_folders_storage_root_path = $7, server_folders_archive_root_label = $8, server_folders_archive_root_path = $9, updated_at = now() WHERE id = 1 `, host, username, skipTLSVerify, serverFoldersAgentScheme, serverFoldersAgentPort, serverFoldersStorageRootLabel, serverFoldersStorageRootPath, serverFoldersArchiveRootLabel, serverFoldersArchiveRootPath, ) } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht gespeichert werden") return } rotationWarning := s.rotateServerFoldersAgentTokenAfterSave(r.Context()) if r.URL.Query().Get("sync") == "false" { settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") return } response := map[string]any{ "settings": settings, } if rotationWarning != "" { response["warning"] = rotationWarning } writeJSON(w, http.StatusOK, response) return } syncWarning := "" var syncResult milestoneSyncResult credentials, err := s.getMilestoneCredentials(r.Context()) if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" { syncResult, err = s.refreshMilestoneTokenAndServerID(r.Context(), credentials) if err != nil { log.Printf("Milestone sync after settings save failed: %v", err) syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token, Recording-Server oder Milestone-Daten konnten nicht vollständig abgerufen werden." } } else { syncWarning = "Milestone-Zugangsdaten wurden gespeichert, konnten aber nicht vollständig geprüft werden." } settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen wurden gespeichert, konnten aber nicht neu geladen werden") return } response := map[string]any{ "settings": settings, } warnings := make([]string, 0, 2) if rotationWarning != "" { warnings = append(warnings, rotationWarning) } if syncWarning != "" { warnings = append(warnings, syncWarning) } if len(warnings) > 0 { response["warning"] = strings.Join(warnings, " ") } else if syncResult.TokenReceived { response["sync"] = syncResult } writeJSON(w, http.StatusOK, response) } // rotateServerFoldersAgentTokenAfterSave erzeugt einen neuen Agent-Token, schiebt // ihn an den Agent und persistiert ihn anschließend verschlüsselt. Schlägt einer // der Schritte fehl, bleibt der bisherige Token unverändert und es wird eine // Warnung zurückgegeben (das Speichern der übrigen Einstellungen bleibt gültig). func (s *Server) rotateServerFoldersAgentTokenAfterSave(ctx context.Context) string { config, err := s.getServerFoldersAgentConfig(ctx) if err != nil { log.Printf("server-folders agent config load failed: %v", err) return "Der Server-Folders-Agent-Token konnte nicht ausgetauscht werden." } refreshedConfig, err := s.rotateServerFoldersAgentToken(ctx, config) if err != nil { log.Printf("server-folders agent token rotation failed: %v", err) return "Der Server-Folders-Agent ist nicht erreichbar. Der Token wurde nicht ausgetauscht." } if err := s.pushServerFoldersAgentConfig(ctx, refreshedConfig); err != nil { log.Printf("server-folders agent config update failed: %v", err) return "Der Server-Folders-Agent-Token wurde gespeichert, aber die Root-Pfade konnten nicht in die Agent-JSON geschrieben werden." } return "" } func (s *Server) handleFetchMilestoneToken(w http.ResponseWriter, r *http.Request) { credentials, err := s.getMilestoneCredentials(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden") return } if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig") return } syncResult, err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials) if err != nil { log.Printf("Milestone token/server sync failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Token, Recording-Server oder Milestone-Daten konnten nicht abgerufen werden") return } settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden") return } writeJSON(w, http.StatusOK, map[string]any{ "settings": settings, "sync": syncResult, }) } func (s *Server) handleFetchMilestoneTokenStream(w http.ResponseWriter, r *http.Request) { credentials, err := s.getMilestoneCredentials(r.Context()) if errors.Is(err, pgx.ErrNoRows) { writeError(w, http.StatusBadRequest, "Milestone-Einstellungen sind noch nicht hinterlegt") return } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Zugangsdaten konnten nicht geladen werden") return } if credentials.Host == "" || credentials.Username == "" || credentials.Password == "" { writeError(w, http.StatusBadRequest, "Milestone-Zugangsdaten sind unvollständig") return } w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("X-Accel-Buffering", "no") _, err = s.refreshMilestoneTokenAndServerIDWithProgress( r.Context(), credentials, func(event milestoneSyncEvent) { writeMilestoneSyncEvent(w, event) }, ) if err != nil { log.Printf("Milestone token/server sync stream failed: %v", err) writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "error", Message: "Milestone-Token, Recording-Server oder Milestone-Daten konnten nicht abgerufen werden", }) return } settings, err := s.getMilestoneSettings(r.Context()) if err != nil { writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "error", Message: "Milestone-Daten wurden gespeichert, konnten aber nicht neu geladen werden", }) return } writeMilestoneSyncEvent(w, milestoneSyncEvent{ Type: "done", Settings: &settings, }) } func (s *Server) getMilestoneSettings(ctx context.Context) (milestoneSettingsResponse, error) { var settings milestoneSettingsResponse var updatedAt time.Time var tokenExpiresAt *time.Time var tokenUpdatedAt *time.Time var serverUpdatedAt *time.Time err := s.db.QueryRow( ctx, ` SELECT host, username, password_encrypted IS NOT NULL, skip_tls_verify, access_token_encrypted IS NOT NULL, token_type, token_expires_at, token_updated_at, COALESCE(server_id, ''), COALESCE(server_name, ''), COALESCE(server_version, ''), server_updated_at, COALESCE(server_folders_agent_scheme, 'http'), COALESCE(server_folders_agent_port, '8099'), server_folders_agent_token_encrypted IS NOT NULL, COALESCE(server_folders_storage_root_label, 'Storage D'), COALESCE(server_folders_storage_root_path, 'D:/Milestone-Speicher'), COALESCE(server_folders_archive_root_label, 'Archive E'), COALESCE(server_folders_archive_root_path, 'E:/'), updated_at FROM milestone_settings WHERE id = 1 LIMIT 1 `, ).Scan( &settings.Host, &settings.Username, &settings.PasswordConfigured, &settings.SkipTLSVerify, &settings.TokenConfigured, &settings.TokenType, &tokenExpiresAt, &tokenUpdatedAt, &settings.ServerID, &settings.ServerName, &settings.ServerVersion, &serverUpdatedAt, &settings.ServerFoldersAgentScheme, &settings.ServerFoldersAgentPort, &settings.ServerFoldersAgentTokenConfigured, &settings.ServerFoldersStorageRootLabel, &settings.ServerFoldersStorageRootPath, &settings.ServerFoldersArchiveRootLabel, &settings.ServerFoldersArchiveRootPath, &updatedAt, ) if err != nil { return settings, err } settings.TokenExpiresAt = tokenExpiresAt settings.TokenUpdatedAt = tokenUpdatedAt settings.ServerUpdatedAt = serverUpdatedAt settings.UpdatedAt = &updatedAt return settings, nil } func (s *Server) getMilestoneCredentials(ctx context.Context) (milestoneCredentials, error) { var credentials milestoneCredentials err := s.db.QueryRow( ctx, ` SELECT host, username, COALESCE(pgp_sym_decrypt(password_encrypted, $1), ''), skip_tls_verify FROM milestone_settings WHERE id = 1 LIMIT 1 `, s.milestoneEncryptionKey(), ).Scan( &credentials.Host, &credentials.Username, &credentials.Password, &credentials.SkipTLSVerify, ) return credentials, err } func milestoneHTTPClient(skipTLSVerify bool) *http.Client { client := &http.Client{ Timeout: 15 * time.Second, } if skipTLSVerify { client.Transport = &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, } } return client } func (s *Server) requestMilestoneToken( ctx context.Context, credentials milestoneCredentials, ) (milestoneTokenResponse, error) { var tokenResponse milestoneTokenResponse form := url.Values{} form.Set("grant_type", "password") form.Set("username", credentials.Username) form.Set("password", credentials.Password) form.Set("client_id", "GrantValidatorClient") requestURL := strings.TrimRight(credentials.Host, "/") + "/API/IDP/connect/token" log.Printf("Requesting Milestone token: url=%s username=%s", requestURL, credentials.Username) request, err := http.NewRequestWithContext( ctx, http.MethodPost, requestURL, strings.NewReader(form.Encode()), ) if err != nil { return tokenResponse, err } request.Header.Set("Content-Type", "application/x-www-form-urlencoded") client := milestoneHTTPClient(credentials.SkipTLSVerify) response, err := client.Do(request) if err != nil { return tokenResponse, err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return tokenResponse, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return tokenResponse, fmt.Errorf( "milestone token request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } if err := json.Unmarshal(body, &tokenResponse); err != nil { return tokenResponse, err } log.Printf( "Milestone token received successfully: url=%s tokenType=%s expiresIn=%d hasAccessToken=%t", requestURL, tokenResponse.TokenType, tokenResponse.ExpiresIn, strings.TrimSpace(tokenResponse.AccessToken) != "", ) return tokenResponse, nil } func normalizeServerFoldersAgentSettings(scheme string, port string) (string, string) { scheme = strings.TrimSpace(strings.ToLower(scheme)) if scheme != "https" { scheme = "http" } port = strings.TrimSpace(strings.TrimPrefix(port, ":")) if port == "" { port = "8099" } for _, char := range port { if char < '0' || char > '9' { return scheme, "8099" } } return scheme, port } func normalizeServerFoldersRootLabel(value string, fallback string) string { value = strings.TrimSpace(value) if value == "" { return fallback } return value } func normalizeServerFoldersRootPath(value string, fallback string) string { value = strings.TrimSpace(value) if value == "" { value = fallback } return strings.ReplaceAll(value, `\`, "/") } func normalizeMilestoneHost(value string) string { value = strings.TrimSpace(value) value = strings.TrimRight(value, "/") if value == "" { return "" } if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") { return value } return "https://" + value } func truncateMilestoneLogBody(body []byte) string { value := strings.TrimSpace(string(body)) if value == "" { return "" } const maxLength = 1000 if len(value) > maxLength { return value[:maxLength] + "..." } return value } func (s *Server) milestoneEncryptionKey() string { value := strings.TrimSpace(os.Getenv("MILESTONE_SETTINGS_KEY")) if value != "" { return value } return string(s.jwtSecret) }