// backend/server_folders_proxy.go package main import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "strings" "time" ) type serverFoldersAgentConfig struct { MilestoneHost string Scheme string Port string Token string StorageRootLabel string StorageRootPath string ArchiveRootLabel string ArchiveRootPath string } type serverFoldersAgentHTTPResponse struct { StatusCode int Body []byte ContentType string } func (s *Server) handleProxyListServerFolders(w http.ResponseWriter, r *http.Request) { s.proxyServerFoldersRequest(w, r, http.MethodGet) } func (s *Server) handleProxyCreateServerFolder(w http.ResponseWriter, r *http.Request) { s.proxyServerFoldersRequest(w, r, http.MethodPost) } func (s *Server) handleProxyRenameServerFolder(w http.ResponseWriter, r *http.Request) { s.proxyServerFoldersRequest(w, r, http.MethodPatch) } func (s *Server) handleProxyDeleteServerFolder(w http.ResponseWriter, r *http.Request) { s.proxyServerFoldersRequest(w, r, http.MethodDelete) } func newServerFoldersAgentHTTPRequest( ctx context.Context, method string, targetURL string, body []byte, config serverFoldersAgentConfig, ) (*http.Request, error) { request, err := http.NewRequestWithContext( ctx, method, targetURL, bytes.NewReader(body), ) if err != nil { return nil, err } request.Header.Set("Accept", "application/json") if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete { request.Header.Set("Content-Type", "application/json") } if config.Token != "" { request.Header.Set("X-Server-Folders-Token", config.Token) } return request, nil } func sendServerFoldersAgentHTTPRequest( client *http.Client, request *http.Request, ) (serverFoldersAgentHTTPResponse, error) { response, err := client.Do(request) if err != nil { return serverFoldersAgentHTTPResponse{}, err } defer response.Body.Close() responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { return serverFoldersAgentHTTPResponse{StatusCode: response.StatusCode}, err } return serverFoldersAgentHTTPResponse{ StatusCode: response.StatusCode, Body: responseBody, ContentType: response.Header.Get("Content-Type"), }, nil } func (s *Server) proxyServerFoldersRequest(w http.ResponseWriter, r *http.Request, method string) { config, err := s.getServerFoldersAgentConfig(r.Context()) if err != nil { writeError(w, http.StatusBadGateway, "Server-Folders-Agent-Konfiguration konnte nicht geladen werden") return } targetURL, err := serverFoldersAgentURL(config, forwardedServerFoldersQuery(r.URL.Query())) if err != nil { writeError(w, http.StatusBadGateway, err.Error()) return } var body []byte if method == http.MethodPost || method == http.MethodPatch || method == http.MethodDelete { body, err = io.ReadAll(io.LimitReader(r.Body, 1<<20)) if err != nil { writeError(w, http.StatusBadRequest, "Request konnte nicht gelesen werden") return } } request, err := newServerFoldersAgentHTTPRequest( r.Context(), method, targetURL, body, config, ) if err != nil { writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden") return } client := &http.Client{ Timeout: 20 * time.Second, } agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request) if err != nil { writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar") return } if agentResponse.StatusCode == http.StatusUnauthorized { refreshedConfig, err := s.rotateServerFoldersAgentToken(r.Context(), config) if err != nil { writeError( w, http.StatusBadGateway, "Server-Folders-Agent hat Unauthorized zurückgegeben. Token-Erneuerung ist fehlgeschlagen. Bitte Agent-Token prüfen oder Milestone-Einstellungen neu speichern.", ) return } request, err = newServerFoldersAgentHTTPRequest( r.Context(), method, targetURL, body, refreshedConfig, ) if err != nil { writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden") return } agentResponse, err = sendServerFoldersAgentHTTPRequest(client, request) if err != nil { writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nach Token-Erneuerung nicht erreichbar") return } if agentResponse.StatusCode == http.StatusUnauthorized { writeError( w, http.StatusBadGateway, "Server-Folders-Agent hat auch nach Token-Erneuerung Unauthorized zurückgegeben. Bitte Agent-Token prüfen.", ) return } } contentType := agentResponse.ContentType if strings.TrimSpace(contentType) == "" { contentType = "application/json" } w.Header().Set("Content-Type", contentType) w.WriteHeader(agentResponse.StatusCode) _, _ = w.Write(agentResponse.Body) } func (s *Server) requestServerFoldersAgent( ctx context.Context, method string, query url.Values, payload any, ) ([]byte, int, error) { config, err := s.getServerFoldersAgentConfig(ctx) if err != nil { return nil, 0, err } targetURL, err := serverFoldersAgentURL(config, query.Encode()) if err != nil { return nil, 0, err } var body []byte if payload != nil { body, err = json.Marshal(payload) if err != nil { return nil, 0, err } } request, err := newServerFoldersAgentHTTPRequest( ctx, method, targetURL, body, config, ) if err != nil { return nil, 0, err } client := &http.Client{ Timeout: 30 * time.Second, } agentResponse, err := sendServerFoldersAgentHTTPRequest(client, request) if err != nil { return nil, 0, err } if agentResponse.StatusCode == http.StatusUnauthorized { refreshedConfig, err := s.rotateServerFoldersAgentToken(ctx, config) if err != nil { return agentResponse.Body, agentResponse.StatusCode, err } request, err = newServerFoldersAgentHTTPRequest( ctx, method, targetURL, body, refreshedConfig, ) if err != nil { return nil, 0, err } agentResponse, err = sendServerFoldersAgentHTTPRequest(client, request) if err != nil { return nil, 0, err } } if agentResponse.StatusCode < 200 || agentResponse.StatusCode >= 300 { return agentResponse.Body, agentResponse.StatusCode, fmt.Errorf( "server-folders-agent request failed: method=%s url=%s status=%d body=%s", method, targetURL, agentResponse.StatusCode, string(agentResponse.Body), ) } return agentResponse.Body, agentResponse.StatusCode, nil } type serverFolderAgentFolderResponse struct { OK bool `json:"ok"` Folder struct { Name string `json:"name"` DisplayName string `json:"displayName,omitempty"` Path string `json:"path"` HasChildren bool `json:"hasChildren"` } `json:"folder"` } func (s *Server) deleteServerFolderPath( ctx context.Context, rootType string, pathValue string, ) error { pathValue = strings.TrimSpace(pathValue) if pathValue == "" { return nil } query := url.Values{} query.Set("rootType", rootType) query.Set("path", pathValue) responseBody, status, err := s.requestServerFoldersAgent( ctx, http.MethodDelete, query, nil, ) if err != nil { if status == http.StatusNotFound { return nil } return fmt.Errorf( "Ordner konnte nicht gelöscht werden: path=%s status=%d body=%s error=%w", pathValue, status, string(responseBody), err, ) } return nil } func (s *Server) renameServerFolderPath( ctx context.Context, rootType string, pathValue string, nextName string, ) (string, error) { pathValue = strings.TrimSpace(pathValue) nextName = strings.TrimSpace(nextName) if pathValue == "" || nextName == "" { return pathValue, nil } query := url.Values{} query.Set("rootType", rootType) responseBody, _, err := s.requestServerFoldersAgent( ctx, http.MethodPatch, query, map[string]any{ "path": pathValue, "name": nextName, }, ) if err != nil { return "", fmt.Errorf( "Ordner konnte nicht umbenannt werden: path=%s name=%s body=%s error=%w", pathValue, nextName, string(responseBody), err, ) } var response serverFolderAgentFolderResponse if err := json.Unmarshal(responseBody, &response); err != nil { return "", err } if strings.TrimSpace(response.Folder.Path) == "" { return "", fmt.Errorf("Server-Folders-Agent hat keinen neuen Pfad zurückgegeben") } return response.Folder.Path, nil } func (s *Server) getServerFoldersAgentConfig(ctx context.Context) (serverFoldersAgentConfig, error) { var config serverFoldersAgentConfig err := s.db.QueryRow( ctx, ` SELECT host, COALESCE(server_folders_agent_scheme, 'http'), COALESCE(server_folders_agent_port, '8099'), COALESCE(pgp_sym_decrypt(server_folders_agent_token_encrypted, $1), ''), 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:/') FROM milestone_settings WHERE id = 1 LIMIT 1 `, s.milestoneEncryptionKey(), ).Scan( &config.MilestoneHost, &config.Scheme, &config.Port, &config.Token, &config.StorageRootLabel, &config.StorageRootPath, &config.ArchiveRootLabel, &config.ArchiveRootPath, ) if err != nil { return config, err } config.Scheme = normalizeServerFoldersAgentScheme(config.Scheme) if config.Scheme == "" { config.Scheme = "http" } config.Port = normalizeServerFoldersAgentPort(config.Port) if config.Port == "" { config.Port = "8099" } config.MilestoneHost = strings.TrimSpace(config.MilestoneHost) config.Token = strings.TrimSpace(config.Token) config.StorageRootLabel = normalizeServerFoldersRootLabel(config.StorageRootLabel, "Storage D") config.StorageRootPath = normalizeServerFoldersRootPath(config.StorageRootPath, "D:/Milestone-Speicher") config.ArchiveRootLabel = normalizeServerFoldersRootLabel(config.ArchiveRootLabel, "Archive E") config.ArchiveRootPath = normalizeServerFoldersRootPath(config.ArchiveRootPath, "E:/") return config, nil } func serverFoldersAgentURL(config serverFoldersAgentConfig, rawQuery string) (string, error) { return serverFoldersAgentURLForPath(config, "/server-folders", rawQuery) } func serverFoldersAgentURLForPath(config serverFoldersAgentConfig, path string, rawQuery string) (string, error) { if strings.TrimSpace(config.MilestoneHost) == "" { return "", fmt.Errorf("Milestone Host fehlt") } normalizedMilestoneHost := strings.TrimSpace(config.MilestoneHost) if !strings.Contains(normalizedMilestoneHost, "://") { normalizedMilestoneHost = "http://" + normalizedMilestoneHost } parsedMilestoneURL, err := url.Parse(normalizedMilestoneHost) if err != nil || parsedMilestoneURL.Hostname() == "" { return "", fmt.Errorf("Milestone Host ist ungültig") } agentURL := url.URL{ Scheme: config.Scheme, Host: parsedMilestoneURL.Hostname() + ":" + config.Port, Path: path, RawQuery: rawQuery, } return agentURL.String(), nil } // generateServerFoldersAgentToken erzeugt einen kryptografisch zufälligen Token. func generateServerFoldersAgentToken() (string, error) { randomBytes := make([]byte, 32) if _, err := rand.Read(randomBytes); err != nil { return "", err } return base64.RawURLEncoding.EncodeToString(randomBytes), nil } // pushServerFoldersAgentToken setzt bzw. rotiert den Token am Agent. Die // Authentifizierung erfolgt mit dem aktuell hinterlegten Token (config.Token). // Solange am Agent noch kein Token gesetzt ist, akzeptiert dieser den Request // auch ohne gültigen Authentifizierungs-Header (Bootstrap). func (s *Server) pushServerFoldersAgentToken( ctx context.Context, config serverFoldersAgentConfig, newToken string, ) error { targetURL, err := serverFoldersAgentURLForPath(config, "/agent-token", "") if err != nil { return err } body, err := json.Marshal(map[string]string{ "token": newToken, }) if err != nil { return err } request, err := http.NewRequestWithContext(ctx, http.MethodPut, targetURL, bytes.NewReader(body)) if err != nil { return err } request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "application/json") if config.Token != "" { request.Header.Set("X-Server-Folders-Token", config.Token) } client := &http.Client{ Timeout: 15 * time.Second, } response, err := client.Do(request) if err != nil { return err } defer response.Body.Close() responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf( "server-folders-agent token rotation failed: url=%s status=%d body=%s", targetURL, response.StatusCode, string(responseBody), ) } return nil } func (s *Server) pushServerFoldersAgentConfig( ctx context.Context, config serverFoldersAgentConfig, ) error { targetURL, err := serverFoldersAgentURLForPath(config, "/agent-config", "") if err != nil { return err } body, err := json.Marshal(map[string]any{ "storageRoots": []map[string]string{ { "label": config.StorageRootLabel, "path": config.StorageRootPath, }, }, "archiveRoots": []map[string]string{ { "label": config.ArchiveRootLabel, "path": config.ArchiveRootPath, }, }, }) if err != nil { return err } request, err := http.NewRequestWithContext(ctx, http.MethodPut, targetURL, bytes.NewReader(body)) if err != nil { return err } request.Header.Set("Accept", "application/json") request.Header.Set("Content-Type", "application/json") if config.Token != "" { request.Header.Set("X-Server-Folders-Token", config.Token) } client := &http.Client{ Timeout: 15 * time.Second, } response, err := client.Do(request) if err != nil { return err } defer response.Body.Close() responseBody, _ := io.ReadAll(io.LimitReader(response.Body, 1<<20)) if response.StatusCode < 200 || response.StatusCode >= 300 { return fmt.Errorf( "server-folders-agent config update failed: url=%s status=%d body=%s", targetURL, response.StatusCode, string(responseBody), ) } return nil } func (s *Server) rotateServerFoldersAgentToken( ctx context.Context, config serverFoldersAgentConfig, ) (serverFoldersAgentConfig, error) { newToken, err := generateServerFoldersAgentToken() if err != nil { return config, err } if err := s.pushServerFoldersAgentToken(ctx, config, newToken); err != nil { return config, err } if _, err := s.db.Exec( ctx, ` UPDATE milestone_settings SET server_folders_agent_token_encrypted = pgp_sym_encrypt($1, $2), updated_at = now() WHERE id = 1 `, newToken, s.milestoneEncryptionKey(), ); err != nil { return config, err } config.Token = newToken return config, nil } func forwardedServerFoldersQuery(values url.Values) string { nextValues := url.Values{} for key, value := range values { switch strings.ToLower(strings.TrimSpace(key)) { case "schema", "scheme", "agentschema", "agentscheme", "port", "agentport", "token", "agenttoken": continue default: for _, item := range value { nextValues.Add(key, item) } } } return nextValues.Encode() } func normalizeServerFoldersAgentScheme(value string) string { value = strings.TrimSpace(strings.ToLower(value)) switch value { case "", "http", "https": return value default: return "" } } func normalizeServerFoldersAgentPort(value string) string { value = strings.TrimSpace(value) value = strings.TrimPrefix(value, ":") if value == "" { return "" } for _, char := range value { if char < '0' || char > '9' { return "" } } return value }