607 lines
15 KiB
Go
607 lines
15 KiB
Go
// 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"`
|
|
|
|
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"`
|
|
ServerFoldersAgentToken string `json:"serverFoldersAgentToken"`
|
|
}
|
|
|
|
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,
|
|
},
|
|
})
|
|
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,
|
|
)
|
|
serverFoldersAgentToken := strings.TrimSpace(input.ServerFoldersAgentToken)
|
|
|
|
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_agent_token_encrypted
|
|
)
|
|
VALUES (
|
|
1,
|
|
$1,
|
|
$2,
|
|
pgp_sym_encrypt($3, $4),
|
|
$5,
|
|
NULL,
|
|
'',
|
|
NULL,
|
|
NULL,
|
|
$6,
|
|
$7,
|
|
CASE
|
|
WHEN $8 = '' THEN NULL
|
|
ELSE pgp_sym_encrypt($8, $4)
|
|
END
|
|
)
|
|
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_agent_token_encrypted = CASE
|
|
WHEN $8 = '' THEN milestone_settings.server_folders_agent_token_encrypted
|
|
ELSE EXCLUDED.server_folders_agent_token_encrypted
|
|
END,
|
|
updated_at = now()
|
|
`,
|
|
host,
|
|
username,
|
|
password,
|
|
encryptionKey,
|
|
skipTLSVerify,
|
|
serverFoldersAgentScheme,
|
|
serverFoldersAgentPort,
|
|
serverFoldersAgentToken,
|
|
)
|
|
} 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_agent_token_encrypted = CASE
|
|
WHEN $6 = '' THEN server_folders_agent_token_encrypted
|
|
ELSE pgp_sym_encrypt($6, $7)
|
|
END,
|
|
updated_at = now()
|
|
WHERE id = 1
|
|
`,
|
|
host,
|
|
username,
|
|
skipTLSVerify,
|
|
serverFoldersAgentScheme,
|
|
serverFoldersAgentPort,
|
|
serverFoldersAgentToken,
|
|
encryptionKey,
|
|
)
|
|
}
|
|
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, "Milestone-Einstellungen konnten nicht gespeichert werden")
|
|
return
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"settings": settings,
|
|
})
|
|
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,
|
|
}
|
|
|
|
if syncWarning != "" {
|
|
response["warning"] = syncWarning
|
|
} else if syncResult.TokenReceived {
|
|
response["sync"] = syncResult
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
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,
|
|
&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 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 "<empty>"
|
|
}
|
|
|
|
const maxLength = 1000
|
|
|
|
if len(value) > maxLength {
|
|
return value[:maxLength] + "...<truncated>"
|
|
}
|
|
|
|
return value
|
|
}
|
|
|
|
func (s *Server) milestoneEncryptionKey() string {
|
|
value := strings.TrimSpace(os.Getenv("MILESTONE_SETTINGS_KEY"))
|
|
if value != "" {
|
|
return value
|
|
}
|
|
|
|
return string(s.jwtSecret)
|
|
}
|