967 lines
24 KiB
Go
967 lines
24 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 Parameter (ohne Token):
|
|
// 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 wird ohne Token gestartet. 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 einer Datei persistiert
|
|
// (Standard: neben der .exe, siehe -token-file / SERVER_FOLDER_AGENT_TOKEN_FILE)
|
|
// 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"
|
|
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"
|
|
)
|
|
|
|
type serverFolderRootType string
|
|
|
|
const (
|
|
serverFolderRootTypeStorage serverFolderRootType = "storage"
|
|
serverFolderRootTypeArchive serverFolderRootType = "archive"
|
|
)
|
|
|
|
type appConfig struct {
|
|
ListenAddr string
|
|
StorageRoots string
|
|
ArchiveRoots string
|
|
Token string
|
|
TokenFile 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"`
|
|
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.TokenFile, config.Token)
|
|
|
|
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)
|
|
log.Printf("token file: %s", config.TokenFile)
|
|
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 {
|
|
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 (default: next to the executable)")
|
|
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))
|
|
}
|
|
if storageRoots == "" {
|
|
storageRoots = legacyRoots
|
|
}
|
|
|
|
archiveRoots := strings.TrimSpace(*archiveRootsFlag)
|
|
if archiveRoots == "" {
|
|
archiveRoots = strings.TrimSpace(os.Getenv(serverArchiveRootsEnv))
|
|
}
|
|
if archiveRoots == "" {
|
|
archiveRoots = legacyRoots
|
|
}
|
|
|
|
token := strings.TrimSpace(*tokenFlag)
|
|
if token == "" {
|
|
token = strings.TrimSpace(os.Getenv(agentTokenEnv))
|
|
}
|
|
|
|
tokenFile := strings.TrimSpace(*tokenFileFlag)
|
|
if tokenFile == "" {
|
|
tokenFile = strings.TrimSpace(os.Getenv(agentTokenFileEnv))
|
|
}
|
|
if tokenFile == "" {
|
|
tokenFile = defaultTokenFilePath()
|
|
}
|
|
|
|
return appConfig{
|
|
ListenAddr: listenAddr,
|
|
StorageRoots: storageRoots,
|
|
ArchiveRoots: archiveRoots,
|
|
Token: token,
|
|
TokenFile: tokenFile,
|
|
}
|
|
}
|
|
|
|
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 einer
|
|
// Datei persistiert, damit er einen Neustart des Agents übersteht.
|
|
type tokenStore struct {
|
|
mu sync.RWMutex
|
|
token string
|
|
filePath string
|
|
}
|
|
|
|
func newTokenStore(filePath string, seed string) *tokenStore {
|
|
store := &tokenStore{filePath: strings.TrimSpace(filePath)}
|
|
|
|
if persisted := store.loadFromFile(); persisted != "" {
|
|
store.token = persisted
|
|
return store
|
|
}
|
|
|
|
if seed = strings.TrimSpace(seed); 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 == "" {
|
|
return ""
|
|
}
|
|
|
|
data, err := os.ReadFile(t.filePath)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return strings.TrimSpace(string(data))
|
|
}
|
|
|
|
func (t *tokenStore) persist(token string) error {
|
|
if t.filePath == "" {
|
|
return nil
|
|
}
|
|
|
|
return os.WriteFile(t.filePath, []byte(token), 0600)
|
|
}
|
|
|
|
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,
|
|
})
|
|
}
|