// 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 } 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 (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 := http.NewRequestWithContext( r.Context(), method, targetURL, bytes.NewReader(body), ) if err != nil { writeError(w, http.StatusBadGateway, "Proxy-Request konnte nicht erstellt werden") return } 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) } client := &http.Client{ Timeout: 20 * time.Second, } response, err := client.Do(request) if err != nil { writeError(w, http.StatusBadGateway, "Server-Folders-Agent ist nicht erreichbar") return } defer response.Body.Close() responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { writeError(w, http.StatusBadGateway, "Antwort vom Server-Folders-Agent konnte nicht gelesen werden") return } contentType := response.Header.Get("Content-Type") if strings.TrimSpace(contentType) == "" { contentType = "application/json" } w.Header().Set("Content-Type", contentType) w.WriteHeader(response.StatusCode) _, _ = w.Write(responseBody) } 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 := http.NewRequestWithContext( ctx, method, targetURL, bytes.NewReader(body), ) if err != nil { return nil, 0, err } request.Header.Set("Accept", "application/json") if payload != nil { request.Header.Set("Content-Type", "application/json") } if config.Token != "" { request.Header.Set("X-Server-Folders-Token", config.Token) } client := &http.Client{ Timeout: 30 * time.Second, } response, err := client.Do(request) if err != nil { return nil, 0, err } defer response.Body.Close() responseBody, err := io.ReadAll(io.LimitReader(response.Body, 4<<20)) if err != nil { return nil, response.StatusCode, err } if response.StatusCode < 200 || response.StatusCode >= 300 { return responseBody, response.StatusCode, fmt.Errorf( "server-folders-agent request failed: method=%s url=%s status=%d body=%s", method, targetURL, response.StatusCode, string(responseBody), ) } return responseBody, response.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), '') FROM milestone_settings WHERE id = 1 LIMIT 1 `, s.milestoneEncryptionKey(), ).Scan( &config.MilestoneHost, &config.Scheme, &config.Port, &config.Token, ) 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) 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 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 }