// 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"` 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"` } 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"` } type milestoneRecordingServersResponse struct { Array []milestoneRecordingServer `json:"array"` } type milestoneRecordingServer struct { ID string `json:"id"` Name string `json:"name"` DisplayName string `json:"displayName"` Version string `json:"version"` ProductVersion string `json:"productVersion"` RecordingServerVersion string `json:"recordingServerVersion"` } 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, }, }) 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 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 ) VALUES ( 1, $1, $2, pgp_sym_encrypt($3, $4), $5, NULL, '', NULL, 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, updated_at = now() `, host, username, password, encryptionKey, skipTLSVerify, ) } 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, updated_at = now() WHERE id = 1 `, host, username, skipTLSVerify, ) } if err != nil { writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht gespeichert werden") return } syncWarning := "" credentials, err := s.getMilestoneCredentials(r.Context()) if err == nil && credentials.Host != "" && credentials.Username != "" && credentials.Password != "" { if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil { log.Printf("Milestone sync after settings save failed: %v", err) syncWarning = "Milestone-Zugangsdaten wurden gespeichert, aber Token oder Server-ID konnten nicht 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, } if syncWarning != "" { response["warning"] = syncWarning } writeJSON(w, http.StatusOK, response) } 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 } if err := s.refreshMilestoneTokenAndServerID(r.Context(), credentials); err != nil { log.Printf("Milestone token/server sync failed: %v", err) writeError(w, http.StatusBadGateway, "Milestone-Token oder Server-ID 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, }) } 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, 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, &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) refreshMilestoneTokenAndServerID( ctx context.Context, credentials milestoneCredentials, ) error { tokenResponse, err := s.requestMilestoneToken(ctx, credentials) if err != nil { return err } accessToken := strings.TrimSpace(tokenResponse.AccessToken) if accessToken == "" { return errors.New("milestone returned empty access token") } tokenType := strings.TrimSpace(tokenResponse.TokenType) if tokenType == "" { tokenType = "Bearer" } expiresIn := tokenResponse.ExpiresIn if expiresIn <= 0 { expiresIn = 3600 } tokenExpiresAt := time.Now().Add(time.Duration(expiresIn) * time.Second) serverID, serverName, serverVersion, err := s.requestMilestoneServerID( ctx, credentials.Host, tokenType, accessToken, credentials.SkipTLSVerify, ) if err != nil { return err } encryptionKey := s.milestoneEncryptionKey() _, err = s.db.Exec( ctx, ` UPDATE milestone_settings SET access_token_encrypted = pgp_sym_encrypt($1, $2), token_type = $3, token_expires_at = $4, token_updated_at = now(), server_id = $5, server_name = $6, server_version = $7, server_updated_at = now(), updated_at = now() WHERE id = 1 `, accessToken, encryptionKey, tokenType, tokenExpiresAt, serverID, serverName, serverVersion, ) return err } func (s *Server) requestMilestoneServerID( ctx context.Context, host string, tokenType string, accessToken string, skipTLSVerify bool, ) (string, string, string, error) { requestURL := strings.TrimRight(host, "/") + "/API/rest/v1/recordingServers" log.Printf( "Requesting Milestone recording server id: url=%s skipTLSVerify=%t", requestURL, skipTLSVerify, ) request, err := http.NewRequestWithContext( ctx, http.MethodGet, requestURL, nil, ) if err != nil { return "", "", "", err } request.Header.Set("Authorization", "Bearer "+accessToken) request.Header.Set("Accept", "application/json") client := milestoneHTTPClient(skipTLSVerify) response, err := client.Do(request) if err != nil { return "", "", "", err } defer response.Body.Close() body, err := io.ReadAll(response.Body) if err != nil { return "", "", "", err } if response.StatusCode < 200 || response.StatusCode >= 300 { return "", "", "", fmt.Errorf( "milestone recording servers request failed: url=%s status=%d body=%s", requestURL, response.StatusCode, truncateMilestoneLogBody(body), ) } var recordingServersResponse milestoneRecordingServersResponse if err := json.Unmarshal(body, &recordingServersResponse); err != nil { return "", "", "", fmt.Errorf( "milestone recording servers response could not be parsed: body=%s error=%w", truncateMilestoneLogBody(body), err, ) } if len(recordingServersResponse.Array) == 0 { return "", "", "", fmt.Errorf( "milestone returned no recording servers: body=%s", truncateMilestoneLogBody(body), ) } recordingServer := recordingServersResponse.Array[0] recordingServerID := strings.TrimSpace(recordingServer.ID) recordingServerName := strings.TrimSpace(recordingServer.DisplayName) if recordingServerName == "" { recordingServerName = strings.TrimSpace(recordingServer.Name) } recordingServerVersion := strings.TrimSpace(recordingServer.Version) if recordingServerVersion == "" { recordingServerVersion = strings.TrimSpace(recordingServer.ProductVersion) } if recordingServerVersion == "" { recordingServerVersion = strings.TrimSpace(recordingServer.RecordingServerVersion) } if recordingServerID == "" { return "", "", "", fmt.Errorf( "milestone returned recording server without id: body=%s", truncateMilestoneLogBody(body), ) } log.Printf( "Milestone recording server received successfully: id=%s displayName=%q version=%q", recordingServerID, recordingServerName, recordingServerVersion, ) return recordingServerID, recordingServerName, recordingServerVersion, 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 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) }