teg/backend/external/agent_main.go
2026-06-22 13:57:42 +02:00

1153 lines
29 KiB
Go

// backend\external\agent_main.go
//
// Eigenständiger Ordner-Agent für den Milestone-/Recording-Server.
//
// Endpunkte:
// GET /health
// GET /server-folders?rootType=storage&path=D:\Milestone
// POST /server-folders
// PATCH /server-folders
// DELETE /server-folders
// PUT /agent-token (Token zur Laufzeit setzen / rotieren)
//
// Start per JSON (empfohlen):
// server-folders-agent.json neben die .exe legen und server-folders-agent.exe starten.
// Alternativ: server-folders-agent.exe -config C:\Pfad\server-folders-agent.json
//
// Beispiel:
// {
// "listenAddr": ":8099",
// "storageRoots": [{ "label": "Storage D", "path": "D:\\Milestone\\Storage" }],
// "archiveRoots": [{ "label": "Archive E", "path": "E:\\Milestone\\Archive" }],
// "token": ""
// }
//
// Start per Parameter bleibt als Fallback möglich:
// server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive"
//
// Alternativ per ENV:
// LISTEN_ADDR=:8099
// SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage
// SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive
//
// Token-Handling:
// Der Agent kann ohne Token gestartet werden. Das Backend erzeugt beim Speichern der
// Milestone-Einstellungen automatisch einen Token und schiebt ihn per
// PUT /agent-token an den Agent. Der Token wird in der JSON-Datei oder, wenn
// explizit konfiguriert, in einer Token-Datei persistiert und überlebt damit
// einen Neustart.
//
// Solange noch kein Token gesetzt ist, ist PUT /agent-token offen (Bootstrap).
// Sobald ein Token gesetzt ist, muss der Client ihn senden:
// X-Server-Folders-Token: <token>
// oder:
// Authorization: Bearer <token>
//
// -token / SERVER_FOLDER_AGENT_TOKEN sind optional und dienen nur als
// Start-Seed, falls noch keine Token-Datei existiert.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"io/fs"
"log"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
)
const (
defaultListenAddr = ":8099"
defaultConfigFileName = "server-folders-agent.json"
defaultTokenFileName = "server-folders-agent.token"
listenAddrEnv = "LISTEN_ADDR"
serverStorageRootsEnv = "SERVER_STORAGE_ROOTS"
serverArchiveRootsEnv = "SERVER_ARCHIVE_ROOTS"
legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS"
agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN"
agentTokenFileEnv = "SERVER_FOLDER_AGENT_TOKEN_FILE"
agentConfigFileEnv = "SERVER_FOLDER_AGENT_CONFIG"
agentPortEnv = "SERVER_FOLDER_AGENT_PORT"
)
type serverFolderRootType string
const (
serverFolderRootTypeStorage serverFolderRootType = "storage"
serverFolderRootTypeArchive serverFolderRootType = "archive"
)
type appConfig struct {
ListenAddr string
StorageRoots string
ArchiveRoots string
Token string
TokenFile string
TokenConfigFile string
}
func (config appConfig) rootsForType(rootType serverFolderRootType) string {
if rootType == serverFolderRootTypeArchive {
return strings.TrimSpace(config.ArchiveRoots)
}
return strings.TrimSpace(config.StorageRoots)
}
type agentConfigFile struct {
ListenAddr string `json:"listenAddr,omitempty"`
Port string `json:"port,omitempty"`
Roots []serverFolderRoot `json:"roots,omitempty"`
StorageRoots []serverFolderRoot `json:"storageRoots,omitempty"`
ArchiveRoots []serverFolderRoot `json:"archiveRoots,omitempty"`
Token string `json:"token,omitempty"`
TokenFile string `json:"tokenFile,omitempty"`
}
type serverFolderRoot struct {
Label string `json:"label"`
Path string `json:"path"`
}
type serverFolderNode struct {
Name string `json:"name"`
Path string `json:"path"`
HasChildren bool `json:"hasChildren"`
}
type listServerFoldersResponse struct {
RootType string `json:"rootType"`
Path string `json:"path"`
ParentPath string `json:"parentPath,omitempty"`
Roots []serverFolderRoot `json:"roots"`
Folders []serverFolderNode `json:"folders"`
}
type createServerFolderRequest struct {
Path string `json:"path"`
ParentPath string `json:"parentPath"`
Name string `json:"name"`
}
type renameServerFolderRequest struct {
Path string `json:"path"`
Name string `json:"name"`
}
func main() {
config := readConfigFromFlagsAndEnv()
store := newTokenStore(config)
mux := http.NewServeMux()
mux.HandleFunc("GET /health", handleHealth)
mux.HandleFunc("GET /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
handleListServerFolders(w, r, config)
}))
mux.HandleFunc("POST /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
handleCreateServerFolder(w, r, config)
}))
mux.HandleFunc("PATCH /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
handleRenameServerFolder(w, r, config)
}))
mux.HandleFunc("DELETE /server-folders", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
handleDeleteServerFolder(w, r, config)
}))
mux.HandleFunc("PUT /agent-token", requireAgentToken(store, func(w http.ResponseWriter, r *http.Request) {
handleSetAgentToken(w, r, store)
}))
server := &http.Server{
Addr: config.ListenAddr,
Handler: logRequests(mux),
ReadHeaderTimeout: 10 * time.Second,
}
log.Printf("server-folders-agent listening on %s", config.ListenAddr)
log.Printf("storage roots: %q", config.StorageRoots)
log.Printf("archive roots: %q", config.ArchiveRoots)
if config.TokenFile != "" {
log.Printf("token file: %s", config.TokenFile)
} else if config.TokenConfigFile != "" {
log.Printf("token config: %s", config.TokenConfigFile)
} else {
log.Printf("token persistence: disabled")
}
log.Printf("token enabled: %t", store.get() != "")
if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatalf("server failed: %v", err)
}
}
func readConfigFromFlagsAndEnv() appConfig {
configFlag := flag.String("config", "", "Path to JSON config file (default: server-folders-agent.json next to the executable)")
addrFlag := flag.String("addr", "", "Listen address, e.g. :8099 or 0.0.0.0:8099")
portFlag := flag.String("port", "", "Listen port, e.g. 8099")
legacyRootsFlag := flag.String("roots", "", "Legacy fallback roots for both storage and archive, e.g. Milestone D=D:\\Milestone")
storageRootsFlag := flag.String("storage-root", "", "Allowed storage roots, e.g. Storage D=D:\\Milestone\\Storage;Storage E=E:\\Milestone\\Storage")
archiveRootsFlag := flag.String("archive-root", "", "Allowed archive roots, e.g. Archive D=D:\\Milestone\\Archive;Archive E=E:\\Milestone\\Archive")
tokenFlag := flag.String("token", "", "Optional start seed token (used only if no token file exists yet)")
tokenFileFlag := flag.String("token-file", "", "Path to persist the runtime token instead of writing it to the JSON config")
flag.Parse()
config := appConfig{}
configPath := strings.TrimSpace(*configFlag)
if configPath == "" {
configPath = strings.TrimSpace(os.Getenv(agentConfigFileEnv))
}
explicitConfigPath := configPath != ""
if configPath == "" {
configPath = defaultConfigFilePath()
}
loadedConfigFile := false
if explicitConfigPath || fileExists(configPath) {
fileConfig, err := readAgentConfigFile(configPath)
if err != nil {
log.Fatalf("config file konnte nicht gelesen werden: %v", err)
}
applyAgentConfigFile(&config, fileConfig)
config.TokenConfigFile = configPath
loadedConfigFile = true
}
listenAddr := firstNonEmpty(
*addrFlag,
os.Getenv(listenAddrEnv),
config.ListenAddr,
)
port := strings.TrimSpace(*portFlag)
if listenAddr == "" && port == "" {
port = strings.TrimSpace(os.Getenv(agentPortEnv))
}
if listenAddr == "" && port != "" {
port = strings.TrimPrefix(port, ":")
listenAddr = ":" + port
}
if listenAddr == "" {
listenAddr = defaultListenAddr
}
legacyRoots := firstNonEmpty(*legacyRootsFlag, os.Getenv(legacyFolderRootsEnv))
storageRoots := firstNonEmpty(
*storageRootsFlag,
os.Getenv(serverStorageRootsEnv),
config.StorageRoots,
)
if storageRoots == "" {
storageRoots = legacyRoots
}
archiveRoots := firstNonEmpty(
*archiveRootsFlag,
os.Getenv(serverArchiveRootsEnv),
config.ArchiveRoots,
)
if archiveRoots == "" {
archiveRoots = legacyRoots
}
token := firstNonEmpty(*tokenFlag, os.Getenv(agentTokenEnv), config.Token)
tokenFile := firstNonEmpty(*tokenFileFlag, os.Getenv(agentTokenFileEnv), config.TokenFile)
if tokenFile == "" {
if loadedConfigFile {
config.TokenConfigFile = configPath
} else {
tokenFile = defaultTokenFilePath()
}
} else {
config.TokenConfigFile = ""
}
return appConfig{
ListenAddr: listenAddr,
StorageRoots: storageRoots,
ArchiveRoots: archiveRoots,
Token: token,
TokenFile: tokenFile,
TokenConfigFile: config.TokenConfigFile,
}
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func readAgentConfigFile(pathValue string) (agentConfigFile, error) {
var config agentConfigFile
data, err := os.ReadFile(pathValue)
if err != nil {
return config, err
}
if err := json.Unmarshal(data, &config); err != nil {
return config, err
}
return config, nil
}
func updateAgentConfigFileToken(pathValue string, token string) error {
data, err := os.ReadFile(pathValue)
if err != nil {
return err
}
values := map[string]any{}
if strings.TrimSpace(string(data)) != "" {
if err := json.Unmarshal(data, &values); err != nil {
return err
}
}
values["token"] = strings.TrimSpace(token)
nextData, err := json.MarshalIndent(values, "", " ")
if err != nil {
return err
}
nextData = append(nextData, '\n')
return os.WriteFile(pathValue, nextData, 0600)
}
func applyAgentConfigFile(config *appConfig, fileConfig agentConfigFile) {
config.ListenAddr = strings.TrimSpace(fileConfig.ListenAddr)
if config.ListenAddr == "" && strings.TrimSpace(fileConfig.Port) != "" {
config.ListenAddr = ":" + strings.TrimPrefix(strings.TrimSpace(fileConfig.Port), ":")
}
legacyRoots := formatServerFolderRoots(fileConfig.Roots)
config.StorageRoots = formatServerFolderRoots(fileConfig.StorageRoots)
if config.StorageRoots == "" {
config.StorageRoots = legacyRoots
}
config.ArchiveRoots = formatServerFolderRoots(fileConfig.ArchiveRoots)
if config.ArchiveRoots == "" {
config.ArchiveRoots = legacyRoots
}
config.Token = strings.TrimSpace(fileConfig.Token)
config.TokenFile = strings.TrimSpace(fileConfig.TokenFile)
}
func formatServerFolderRoots(roots []serverFolderRoot) string {
parts := make([]string, 0, len(roots))
for _, root := range roots {
pathValue := strings.TrimSpace(root.Path)
if pathValue == "" {
continue
}
label := strings.TrimSpace(root.Label)
if label == "" {
parts = append(parts, pathValue)
continue
}
parts = append(parts, label+"="+pathValue)
}
return strings.Join(parts, ";")
}
func fileExists(pathValue string) bool {
if strings.TrimSpace(pathValue) == "" {
return false
}
info, err := os.Stat(pathValue)
return err == nil && !info.IsDir()
}
func defaultConfigFilePath() string {
if exePath, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exePath), defaultConfigFileName)
}
return defaultConfigFileName
}
func defaultTokenFilePath() string {
if exePath, err := os.Executable(); err == nil {
return filepath.Join(filepath.Dir(exePath), defaultTokenFileName)
}
return defaultTokenFileName
}
func handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func handleListServerFolders(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
requestedPath := strings.TrimSpace(r.URL.Query().Get("path"))
if requestedPath == "" {
requestedPath = roots[0].Path
}
targetPath, err := cleanAbsolutePath(requestedPath)
if err != nil {
writeError(w, http.StatusBadRequest, "Ungültiger Pfad")
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
return
}
resolvedTargetPath, err := resolveExistingPath(targetPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(resolvedTargetPath, roots) {
writeError(w, http.StatusForbidden, "Pfad verlässt den freigegebenen Bereich")
return
}
info, err := os.Stat(resolvedTargetPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
return
}
if !info.IsDir() {
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
return
}
entries, err := os.ReadDir(resolvedTargetPath)
if err != nil {
writeError(w, http.StatusForbidden, "Ordnerinhalt konnte nicht gelesen werden")
return
}
folders := make([]serverFolderNode, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() {
continue
}
childPath := filepath.Join(resolvedTargetPath, entry.Name())
childPath, err = cleanAbsolutePath(childPath)
if err != nil {
continue
}
if !isPathAllowed(childPath, roots) {
continue
}
folders = append(folders, serverFolderNode{
Name: entry.Name(),
Path: childPath,
HasChildren: folderHasVisibleSubdirectories(childPath, roots),
})
}
sort.SliceStable(folders, func(i int, j int) bool {
return strings.ToLower(folders[i].Name) < strings.ToLower(folders[j].Name)
})
writeJSON(w, http.StatusOK, listServerFoldersResponse{
RootType: string(rootType),
Path: resolvedTargetPath,
ParentPath: allowedParentPath(resolvedTargetPath, roots),
Roots: roots,
Folders: folders,
})
}
func handleCreateServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var input createServerFolderRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
targetPath, err := resolveCreateFolderTarget(input)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Zielpfad ist nicht freigegeben")
return
}
parentPath := filepath.Dir(targetPath)
resolvedParentPath, err := resolveExistingPath(parentPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Übergeordneter Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Übergeordneter Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(resolvedParentPath, roots) {
writeError(w, http.StatusForbidden, "Übergeordneter Ordner ist nicht freigegeben")
return
}
parentInfo, err := os.Stat(resolvedParentPath)
if err != nil {
writeError(w, http.StatusBadRequest, "Übergeordneter Ordner konnte nicht gelesen werden")
return
}
if !parentInfo.IsDir() {
writeError(w, http.StatusBadRequest, "Übergeordneter Pfad ist kein Ordner")
return
}
if err := os.Mkdir(targetPath, 0750); err != nil {
if errors.Is(err, fs.ErrExist) {
writeError(w, http.StatusConflict, "Ordner existiert bereits")
return
}
writeError(w, http.StatusForbidden, "Ordner konnte nicht erstellt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": serverFolderNode{
Name: filepath.Base(targetPath),
Path: targetPath,
HasChildren: false,
},
})
}
func handleRenameServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
var input renameServerFolderRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
sourcePath, err := resolveExistingPath(input.Path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(sourcePath, roots) {
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
return
}
if isConfiguredRootPath(sourcePath, roots) {
writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht umbenannt werden")
return
}
info, err := os.Stat(sourcePath)
if err != nil {
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
return
}
if !info.IsDir() {
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
return
}
nextName := strings.TrimSpace(input.Name)
if !isSafeSingleFolderName(nextName) {
writeError(w, http.StatusBadRequest, "Neuer Ordnername ist ungültig")
return
}
parentPath := filepath.Dir(sourcePath)
targetPath, err := cleanAbsolutePath(filepath.Join(parentPath, nextName))
if err != nil {
writeError(w, http.StatusBadRequest, "Neuer Zielpfad ist ungültig")
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Neuer Zielpfad ist nicht freigegeben")
return
}
if strings.EqualFold(sourcePath, targetPath) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": serverFolderNode{
Name: filepath.Base(sourcePath),
Path: sourcePath,
HasChildren: folderHasVisibleSubdirectories(sourcePath, roots),
},
})
return
}
if _, err := os.Stat(targetPath); err == nil {
writeError(w, http.StatusConflict, "Zielordner existiert bereits")
return
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusBadRequest, "Zielordner konnte nicht geprüft werden")
return
}
if err := os.Rename(sourcePath, targetPath); err != nil {
writeError(w, http.StatusForbidden, "Ordner konnte nicht umbenannt werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"folder": serverFolderNode{
Name: filepath.Base(targetPath),
Path: targetPath,
HasChildren: folderHasVisibleSubdirectories(targetPath, roots),
},
})
}
func handleDeleteServerFolder(w http.ResponseWriter, r *http.Request, config appConfig) {
rootType := serverFolderRootTypeFromRequest(r)
roots, err := getConfiguredServerFolderRoots(config.rootsForType(rootType), rootType)
if err != nil {
writeError(w, http.StatusBadRequest, err.Error())
return
}
requestedPath := strings.TrimSpace(r.URL.Query().Get("path"))
if requestedPath == "" && r.Body != nil {
var input struct {
Path string `json:"path"`
}
if err := readJSON(r, &input); err == nil {
requestedPath = strings.TrimSpace(input.Path)
}
}
if requestedPath == "" {
writeError(w, http.StatusBadRequest, "Pfad fehlt")
return
}
targetPath, err := resolveExistingPath(requestedPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
writeError(w, http.StatusNotFound, "Ordner wurde nicht gefunden")
return
}
writeError(w, http.StatusBadRequest, "Ordner konnte nicht geprüft werden")
return
}
if !isPathAllowed(targetPath, roots) {
writeError(w, http.StatusForbidden, "Pfad ist nicht freigegeben")
return
}
if isConfiguredRootPath(targetPath, roots) {
writeError(w, http.StatusBadRequest, "Root-Ordner darf nicht gelöscht werden")
return
}
info, err := os.Stat(targetPath)
if err != nil {
writeError(w, http.StatusBadRequest, "Ordner konnte nicht gelesen werden")
return
}
if !info.IsDir() {
writeError(w, http.StatusBadRequest, "Pfad ist kein Ordner")
return
}
if err := os.Remove(targetPath); err != nil {
if errors.Is(err, os.ErrNotExist) {
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"deleted": false,
"path": targetPath,
})
return
}
writeError(w, http.StatusConflict, "Ordner konnte nicht gelöscht werden. Er ist vermutlich nicht leer.")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"deleted": true,
"path": targetPath,
})
}
func isConfiguredRootPath(pathValue string, roots []serverFolderRoot) bool {
pathValue, err := cleanAbsolutePath(pathValue)
if err != nil {
return false
}
for _, root := range roots {
rootPath, err := cleanAbsolutePath(root.Path)
if err != nil {
continue
}
if strings.EqualFold(pathValue, rootPath) {
return true
}
}
return false
}
func serverFolderRootTypeFromRequest(r *http.Request) serverFolderRootType {
value := strings.TrimSpace(strings.ToLower(r.URL.Query().Get("rootType")))
if value == "" {
value = strings.TrimSpace(strings.ToLower(r.URL.Query().Get("type")))
}
switch value {
case "archive", "archive-root", "archive_root":
return serverFolderRootTypeArchive
default:
return serverFolderRootTypeStorage
}
}
func getConfiguredServerFolderRoots(rawValue string, rootType serverFolderRootType) ([]serverFolderRoot, error) {
rawValue = strings.TrimSpace(rawValue)
if rawValue == "" {
return nil, fmt.Errorf("%s-root fehlt", rootType)
}
parts := strings.Split(rawValue, ";")
roots := make([]serverFolderRoot, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
label := ""
pathValue := part
if before, after, found := strings.Cut(part, "="); found {
label = strings.TrimSpace(before)
pathValue = strings.TrimSpace(after)
}
rootPath, err := cleanAbsolutePath(pathValue)
if err != nil {
return nil, fmt.Errorf("ungültiger Root-Pfad: %s", pathValue)
}
if label == "" {
label = rootPath
}
roots = append(roots, serverFolderRoot{
Label: label,
Path: rootPath,
})
}
if len(roots) == 0 {
return nil, fmt.Errorf("keine gültigen Root-Pfade konfiguriert")
}
return roots, nil
}
func cleanAbsolutePath(value string) (string, error) {
value = strings.TrimSpace(value)
if value == "" {
return "", fmt.Errorf("Pfad fehlt")
}
cleaned := filepath.Clean(value)
absolute, err := filepath.Abs(cleaned)
if err != nil {
return "", err
}
return absolute, nil
}
func resolveExistingPath(value string) (string, error) {
cleaned, err := cleanAbsolutePath(value)
if err != nil {
return "", err
}
resolved, err := filepath.EvalSymlinks(cleaned)
if err != nil {
return "", err
}
return cleanAbsolutePath(resolved)
}
func resolveCreateFolderTarget(input createServerFolderRequest) (string, error) {
if strings.TrimSpace(input.Path) != "" {
return cleanAbsolutePath(input.Path)
}
parentPath := strings.TrimSpace(input.ParentPath)
folderName := strings.TrimSpace(input.Name)
if parentPath == "" {
return "", fmt.Errorf("parentPath fehlt")
}
if !isSafeSingleFolderName(folderName) {
return "", fmt.Errorf("Ordnername ist ungültig")
}
return cleanAbsolutePath(filepath.Join(parentPath, folderName))
}
func isSafeSingleFolderName(value string) bool {
value = strings.TrimSpace(value)
if value == "" || value == "." || value == ".." {
return false
}
if strings.ContainsAny(value, `/\`) {
return false
}
if strings.ContainsAny(value, `<>:"|?*`) {
return false
}
return true
}
func isPathAllowed(pathValue string, roots []serverFolderRoot) bool {
pathValue, err := cleanAbsolutePath(pathValue)
if err != nil {
return false
}
for _, root := range roots {
rootPath, err := cleanAbsolutePath(root.Path)
if err != nil {
continue
}
if pathWithinRoot(pathValue, rootPath) {
return true
}
}
return false
}
func pathWithinRoot(pathValue string, rootPath string) bool {
rel, err := filepath.Rel(rootPath, pathValue)
if err != nil {
return false
}
return rel == "." ||
(rel != ".." &&
!strings.HasPrefix(rel, ".."+string(filepath.Separator)) &&
!filepath.IsAbs(rel))
}
func allowedParentPath(pathValue string, roots []serverFolderRoot) string {
parentPath := filepath.Dir(pathValue)
if parentPath == pathValue {
return ""
}
if !isPathAllowed(parentPath, roots) {
return ""
}
return parentPath
}
func folderHasVisibleSubdirectories(pathValue string, roots []serverFolderRoot) bool {
entries, err := os.ReadDir(pathValue)
if err != nil {
return false
}
for _, entry := range entries {
if !entry.IsDir() {
continue
}
childPath, err := cleanAbsolutePath(filepath.Join(pathValue, entry.Name()))
if err != nil {
continue
}
if isPathAllowed(childPath, roots) {
return true
}
}
return false
}
// tokenStore hält den zur Laufzeit gültigen Agent-Token. Der Token wird in der
// JSON-Config oder einer separaten Datei persistiert.
type tokenStore struct {
mu sync.RWMutex
token string
filePath string
configFilePath string
}
func newTokenStore(config appConfig) *tokenStore {
store := &tokenStore{
filePath: strings.TrimSpace(config.TokenFile),
configFilePath: strings.TrimSpace(config.TokenConfigFile),
}
if persisted := store.loadFromFile(); persisted != "" {
store.token = persisted
return store
}
if seed := strings.TrimSpace(config.Token); seed != "" {
store.token = seed
if err := store.persist(seed); err != nil {
log.Printf("token seed konnte nicht persistiert werden: %v", err)
}
}
return store
}
func (t *tokenStore) get() string {
t.mu.RLock()
defer t.mu.RUnlock()
return t.token
}
func (t *tokenStore) set(token string) error {
token = strings.TrimSpace(token)
t.mu.Lock()
defer t.mu.Unlock()
if err := t.persist(token); err != nil {
return err
}
t.token = token
return nil
}
func (t *tokenStore) loadFromFile() string {
if t.filePath != "" {
data, err := os.ReadFile(t.filePath)
if err != nil {
return ""
}
return strings.TrimSpace(string(data))
}
if t.configFilePath == "" {
return ""
}
config, err := readAgentConfigFile(t.configFilePath)
if err != nil {
return ""
}
return strings.TrimSpace(config.Token)
}
func (t *tokenStore) persist(token string) error {
if t.filePath != "" {
return os.WriteFile(t.filePath, []byte(token), 0600)
}
if t.configFilePath != "" {
return updateAgentConfigFileToken(t.configFilePath, token)
}
return nil
}
type setAgentTokenRequest struct {
Token string `json:"token"`
}
func handleSetAgentToken(w http.ResponseWriter, r *http.Request, store *tokenStore) {
var input setAgentTokenRequest
if err := readJSON(r, &input); err != nil {
writeError(w, http.StatusBadRequest, "Invalid JSON")
return
}
nextToken := strings.TrimSpace(input.Token)
if nextToken == "" {
writeError(w, http.StatusBadRequest, "Token fehlt")
return
}
if err := store.set(nextToken); err != nil {
log.Printf("Token konnte nicht gespeichert werden: %v", err)
writeError(w, http.StatusInternalServerError, "Token konnte nicht gespeichert werden")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
})
}
func requireAgentToken(store *tokenStore, next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
requiredToken := store.get()
if requiredToken == "" {
next(w, r)
return
}
token := strings.TrimSpace(r.Header.Get("X-Server-Folders-Token"))
if token == "" {
authHeader := strings.TrimSpace(r.Header.Get("Authorization"))
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = strings.TrimSpace(authHeader[len("bearer "):])
}
}
if token != requiredToken {
writeError(w, http.StatusUnauthorized, "Unauthorized")
return
}
next(w, r)
}
}
func logRequests(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Printf("%s %s from %s", r.Method, r.URL.RequestURI(), r.RemoteAddr)
next.ServeHTTP(w, r)
})
}
func readJSON(r *http.Request, target any) error {
defer r.Body.Close()
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
decoder.DisallowUnknownFields()
return decoder.Decode(target)
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]any{
"error": message,
})
}