// 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 // // Start per Parameter: // server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME" // // Pflichtwerte: -storage-root, -archive-root und -token. Alternativ können die passenden ENV-Variablen gesetzt werden. // // Alternativ per ENV: // LISTEN_ADDR=:8099 // SERVER_STORAGE_ROOTS=Storage D=D:\Milestone\Storage // SERVER_ARCHIVE_ROOTS=Archive E=E:\Milestone\Archive // SERVER_FOLDER_AGENT_TOKEN=optional-geheimer-token // // Wenn SERVER_FOLDER_AGENT_TOKEN oder -token gesetzt ist, muss der Client senden: // X-Server-Folders-Token: // oder: // Authorization: Bearer package main import ( "encoding/json" "errors" "flag" "fmt" "io" "io/fs" "log" "net/http" "os" "path/filepath" "sort" "strings" "syscall" "time" "unsafe" ) const ( defaultListenAddr = ":8099" listenAddrEnv = "LISTEN_ADDR" serverStorageRootsEnv = "SERVER_STORAGE_ROOTS" serverArchiveRootsEnv = "SERVER_ARCHIVE_ROOTS" legacyFolderRootsEnv = "SERVER_FOLDER_ROOTS" agentTokenEnv = "SERVER_FOLDER_AGENT_TOKEN" ) type serverFolderRootType string const ( serverFolderRootTypeStorage serverFolderRootType = "storage" serverFolderRootTypeArchive serverFolderRootType = "archive" ) type appConfig struct { ListenAddr string StorageRoots string ArchiveRoots string Token string } func (config appConfig) rootsForType(rootType serverFolderRootType) string { if rootType == serverFolderRootTypeArchive { return strings.TrimSpace(config.ArchiveRoots) } return strings.TrimSpace(config.StorageRoots) } type serverFolderRoot struct { Label string `json:"label"` Path string `json:"path"` } type serverFolderNode struct { Name string `json:"name"` DisplayName string `json:"displayName,omitempty"` 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"` } func main() { config := readConfigFromFlagsAndEnv() mux := http.NewServeMux() mux.HandleFunc("GET /health", handleHealth) mux.HandleFunc("GET /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { handleListServerFolders(w, r, config) })) mux.HandleFunc("POST /server-folders", requireAgentToken(config.Token, func(w http.ResponseWriter, r *http.Request) { handleCreateServerFolder(w, r, config) })) 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) log.Printf("token enabled: %t", config.Token != "") if err := server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { log.Fatalf("server failed: %v", err) } } func readConfigFromFlagsAndEnv() appConfig { 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", "", "Required access token for X-Server-Folders-Token") flag.Parse() listenAddr := strings.TrimSpace(*addrFlag) if listenAddr == "" { listenAddr = strings.TrimSpace(os.Getenv(listenAddrEnv)) } port := strings.TrimSpace(*portFlag) if listenAddr == "" && port != "" { port = strings.TrimPrefix(port, ":") listenAddr = ":" + port } if listenAddr == "" { listenAddr = defaultListenAddr } legacyRoots := strings.TrimSpace(*legacyRootsFlag) if legacyRoots == "" { legacyRoots = strings.TrimSpace(os.Getenv(legacyFolderRootsEnv)) } storageRoots := strings.TrimSpace(*storageRootsFlag) if storageRoots == "" { storageRoots = strings.TrimSpace(os.Getenv(serverStorageRootsEnv)) } archiveRoots := strings.TrimSpace(*archiveRootsFlag) if archiveRoots == "" { archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv)) } token := strings.TrimSpace(*tokenFlag) if token == "" { token = strings.TrimSpace(os.Getenv(agentTokenEnv)) } config := appConfig{ ListenAddr: listenAddr, StorageRoots: storageRoots, ArchiveRoots: archiveRoots, Token: token, } validateRequiredConfig(config, legacyRoots) return config } func validateRequiredConfig(config appConfig, legacyRoots string) { missing := make([]string, 0, 3) if strings.TrimSpace(config.StorageRoots) == "" { missing = append(missing, "-storage-root oder "+serverStorageRootsEnv) } if strings.TrimSpace(config.ArchiveRoots) == "" { missing = append(missing, "-archive-root oder "+serverArchiveRootsEnv) } if strings.TrimSpace(config.Token) == "" { missing = append(missing, "-token oder "+agentTokenEnv) } if len(missing) == 0 { return } if strings.TrimSpace(legacyRoots) != "" { log.Printf("Hinweis: -roots/%s wird nicht mehr als Ersatz fuer -storage-root und -archive-root akzeptiert.", legacyFolderRootsEnv) } log.Fatalf( `Pflichtparameter fehlen: %s. Beispiel: server-folders-agent.exe -port 8099 -storage-root "Storage D=D:\Milestone\Storage" -archive-root "Archive E=E:\Milestone\Archive" -token "CHANGE-ME"`, strings.Join(missing, ", "), ) } 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 } fileSystemName := entry.Name() folders = append(folders, serverFolderNode{ Name: fileSystemName, DisplayName: windowsDisplayNameForPath(childPath, fileSystemName), 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), DisplayName: windowsDisplayNameForPath(targetPath, filepath.Base(targetPath)), Path: targetPath, HasChildren: 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 } const ( shgfiDisplayName = 0x000000200 ) type shFileInfo struct { HIcon uintptr IIcon int32 DwAttributes uint32 SzDisplayName [260]uint16 SzTypeName [80]uint16 } var ( shell32DLL = syscall.NewLazyDLL("shell32.dll") procSHGetFileInfo = shell32DLL.NewProc("SHGetFileInfoW") ) func windowsDisplayNameForPath(pathValue string, fallback string) string { pathValue = strings.TrimSpace(pathValue) fallback = strings.TrimSpace(fallback) if pathValue == "" { return fallback } pathPointer, err := syscall.UTF16PtrFromString(pathValue) if err != nil { return fallback } var info shFileInfo result, _, _ := procSHGetFileInfo.Call( uintptr(unsafe.Pointer(pathPointer)), 0, uintptr(unsafe.Pointer(&info)), unsafe.Sizeof(info), shgfiDisplayName, ) if result == 0 { return fallback } displayName := strings.TrimSpace(syscall.UTF16ToString(info.SzDisplayName[:])) if displayName == "" { return fallback } return displayName } func requireAgentToken(requiredToken string, next http.HandlerFunc) http.HandlerFunc { requiredToken = strings.TrimSpace(requiredToken) return func(w http.ResponseWriter, r *http.Request) { 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, }) }