teg/backend/server_folders_proxy.go
2026-06-12 16:51:33 +02:00

460 lines
10 KiB
Go

// 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
}