// 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: // oder: // Authorization: Bearer // // -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, }) }