feat: Add file manager package — VueFinder-compatible NATS request-reply handler
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
All checks were successful
Test Asgard Runner / test (push) Successful in 2s
Implements companion-agent/internal/filemanager with full installDir jail
enforcement (Clean + EvalSymlinks + HasPrefix on every path). Handles all
VueFinder operations: list, delete, rename, copy, move, mkdir, mkfile,
search, preview, save, upload. Wires into daemon.go as a 6th NATS
subscription on corrosion.{license_id}.files.cmd.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
"github.com/vigilcyber/corrosion-companion/internal/deploy"
|
||||||
|
"github.com/vigilcyber/corrosion-companion/internal/filemanager"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/files"
|
"github.com/vigilcyber/corrosion-companion/internal/files"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/process"
|
"github.com/vigilcyber/corrosion-companion/internal/process"
|
||||||
"github.com/vigilcyber/corrosion-companion/internal/update"
|
"github.com/vigilcyber/corrosion-companion/internal/update"
|
||||||
@@ -32,6 +33,7 @@ type Daemon struct {
|
|||||||
cfg *DaemonConfig
|
cfg *DaemonConfig
|
||||||
gameServer *process.GameServer
|
gameServer *process.GameServer
|
||||||
fileOps *files.Operations
|
fileOps *files.Operations
|
||||||
|
fm *filemanager.FileManager
|
||||||
updater *update.Updater
|
updater *update.Updater
|
||||||
deployer *deploy.Deployer
|
deployer *deploy.Deployer
|
||||||
subscriptions []*nats.Subscription
|
subscriptions []*nats.Subscription
|
||||||
@@ -73,6 +75,7 @@ func (a *gameServerAdapter) UpdatePath(path string) {
|
|||||||
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
||||||
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
gameServer := process.NewGameServer(cfg.GameServerPath, cfg.GameServerArgs)
|
||||||
fileOps := files.NewOperations()
|
fileOps := files.NewOperations()
|
||||||
|
fm := filemanager.New(cfg.InstallDir)
|
||||||
updater := update.NewUpdater(cfg.Version)
|
updater := update.NewUpdater(cfg.Version)
|
||||||
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
adapter := &gameServerAdapter{gs: gameServer, cfg: cfg}
|
||||||
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
deployer := deploy.NewDeployer(nc, cfg.LicenseID, cfg.InstallDir, adapter)
|
||||||
@@ -82,6 +85,7 @@ func NewDaemon(nc *nats.Conn, cfg *DaemonConfig) (*Daemon, error) {
|
|||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
gameServer: gameServer,
|
gameServer: gameServer,
|
||||||
fileOps: fileOps,
|
fileOps: fileOps,
|
||||||
|
fm: fm,
|
||||||
updater: updater,
|
updater: updater,
|
||||||
deployer: deployer,
|
deployer: deployer,
|
||||||
}
|
}
|
||||||
@@ -118,6 +122,11 @@ func (d *Daemon) Run(ctx context.Context) error {
|
|||||||
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
return fmt.Errorf("failed to subscribe to deploy commands: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Subscribe to file manager commands (VueFinder-compatible request-reply)
|
||||||
|
if err := d.subscribeFileManager(); err != nil {
|
||||||
|
return fmt.Errorf("failed to subscribe to file manager commands: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
log.Println("All subscriptions active")
|
log.Println("All subscriptions active")
|
||||||
|
|
||||||
// Start heartbeat ticker
|
// Start heartbeat ticker
|
||||||
@@ -338,6 +347,26 @@ func (d *Daemon) subscribeDeployCommand() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// subscribeFileManager subscribes to the VueFinder-compatible file manager
|
||||||
|
// command subject. All operations (list, delete, rename, copy, move, mkdir,
|
||||||
|
// mkfile, search, preview, save, upload) are handled by the filemanager package
|
||||||
|
// which enforces the installDir jail on every path.
|
||||||
|
func (d *Daemon) subscribeFileManager() error {
|
||||||
|
subject := fmt.Sprintf("corrosion.%s.files.cmd", d.cfg.LicenseID)
|
||||||
|
|
||||||
|
sub, err := d.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||||
|
d.fm.HandleNatsRequest(msg)
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
d.subscriptions = append(d.subscriptions, sub)
|
||||||
|
log.Printf("Subscribed to: %s", subject)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// handleFileOperation processes file operation requests
|
// handleFileOperation processes file operation requests
|
||||||
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
func (d *Daemon) handleFileOperation(msg *nats.Msg) {
|
||||||
// Parse common fields
|
// Parse common fields
|
||||||
|
|||||||
735
companion-agent/internal/filemanager/filemanager.go
Normal file
735
companion-agent/internal/filemanager/filemanager.go
Normal file
@@ -0,0 +1,735 @@
|
|||||||
|
package filemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// maxReadSize is the maximum file size allowed for GetContent (5 MB).
|
||||||
|
// This guards against accidentally reading large binary files into memory.
|
||||||
|
maxReadSize = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
// maxSearchResults caps the number of results returned by Search.
|
||||||
|
maxSearchResults = 100
|
||||||
|
|
||||||
|
// storageName is the VueFinder storage identifier used in all path strings.
|
||||||
|
storageName = "server"
|
||||||
|
|
||||||
|
// adapterName is the VueFinder adapter field included in every list/search response.
|
||||||
|
adapterName = "local"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FileManager handles sandboxed filesystem operations for the game server
|
||||||
|
// install directory. All operations are confined to installDir — any path
|
||||||
|
// that resolves outside that boundary is rejected with an error.
|
||||||
|
type FileManager struct {
|
||||||
|
// installDir is the absolute, symlink-resolved root of the jail.
|
||||||
|
// It is set once at construction and never changes.
|
||||||
|
installDir string
|
||||||
|
}
|
||||||
|
|
||||||
|
// New creates a FileManager jailed to installDir. The directory is cleaned and
|
||||||
|
// made absolute but NOT required to exist at construction time — the daemon may
|
||||||
|
// start before the install completes.
|
||||||
|
func New(installDir string) *FileManager {
|
||||||
|
// Clean and make absolute so comparisons are deterministic even if the
|
||||||
|
// caller passed a relative or un-normalised path.
|
||||||
|
abs, err := filepath.Abs(filepath.Clean(installDir))
|
||||||
|
if err != nil {
|
||||||
|
// Abs only fails on systems where os.Getwd() fails; fall back to raw value.
|
||||||
|
log.Printf("filemanager: warning: could not make installDir absolute (%v), using raw value", err)
|
||||||
|
abs = filepath.Clean(installDir)
|
||||||
|
}
|
||||||
|
return &FileManager{installDir: abs}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Public API — one method per VueFinder operation
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// List returns the contents of the directory identified by storagePath.
|
||||||
|
func (fm *FileManager) List(storagePath string) (*ListResponse, error) {
|
||||||
|
abs, rel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(abs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := buildFileItems(entries, abs, rel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(rel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes every item listed in items from within storagePath's directory.
|
||||||
|
// After deletion it returns a fresh listing of the parent directory.
|
||||||
|
func (fm *FileManager) Delete(storagePath string, items []string) (*ListResponse, error) {
|
||||||
|
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
itemAbs, _, err := fm.parseAndResolve(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||||
|
}
|
||||||
|
if err := os.RemoveAll(itemAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to delete %q: %w", item, err)
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: deleted %s", itemAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return a fresh listing of the parent so the frontend can update immediately.
|
||||||
|
entries, err := os.ReadDir(parentAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot re-read directory after delete: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(parentRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rename renames item inside storagePath to newName.
|
||||||
|
// newName must be a bare filename, not a path — slashes are rejected.
|
||||||
|
func (fm *FileManager) Rename(storagePath string, item string, newName string) (*ListResponse, error) {
|
||||||
|
if strings.ContainsAny(newName, "/\\") {
|
||||||
|
return nil, fmt.Errorf("new name must not contain path separators")
|
||||||
|
}
|
||||||
|
if newName == "" || newName == "." || newName == ".." {
|
||||||
|
return nil, fmt.Errorf("invalid new name %q", newName)
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
itemAbs, _, err := fm.parseAndResolve(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
destAbs := filepath.Join(parentAbs, newName)
|
||||||
|
// Verify the destination is also inside the jail before committing.
|
||||||
|
if err := fm.checkWithinJail(destAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("destination escapes jail: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(itemAbs, destAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("rename failed: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: renamed %s -> %s", itemAbs, destAbs)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(parentAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot re-read directory after rename: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(parentRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFolder creates a new directory named name inside storagePath.
|
||||||
|
func (fm *FileManager) CreateFolder(storagePath string, name string) (*ListResponse, error) {
|
||||||
|
if err := validateBareName(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newDirAbs := filepath.Join(parentAbs, name)
|
||||||
|
if err := fm.checkWithinJail(newDirAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("target escapes jail: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(newDirAbs, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create directory: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: created directory %s", newDirAbs)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(parentAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot re-read directory after mkdir: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(parentRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateFile creates an empty file named name inside storagePath.
|
||||||
|
func (fm *FileManager) CreateFile(storagePath string, name string) (*ListResponse, error) {
|
||||||
|
if err := validateBareName(name); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
newFileAbs := filepath.Join(parentAbs, name)
|
||||||
|
if err := fm.checkWithinJail(newFileAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("target escapes jail: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.OpenFile(newFileAbs, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create file: %w", err)
|
||||||
|
}
|
||||||
|
f.Close()
|
||||||
|
log.Printf("filemanager: created file %s", newFileAbs)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(parentAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot re-read directory after mkfile: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(parentRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContent reads and returns the UTF-8 content of the file at storagePath.
|
||||||
|
// Reading is capped at maxReadSize (5 MB) to avoid loading large binaries.
|
||||||
|
func (fm *FileManager) GetContent(storagePath string) (string, error) {
|
||||||
|
abs, _, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := os.Stat(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot stat file: %w", err)
|
||||||
|
}
|
||||||
|
if info.IsDir() {
|
||||||
|
return "", fmt.Errorf("path is a directory, not a file")
|
||||||
|
}
|
||||||
|
if info.Size() > maxReadSize {
|
||||||
|
return "", fmt.Errorf("file size %d bytes exceeds read limit of %d bytes", info.Size(), maxReadSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(abs)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("cannot read file: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveContent overwrites the file at storagePath with content.
|
||||||
|
func (fm *FileManager) SaveContent(storagePath string, content string) error {
|
||||||
|
abs, _, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure parent directory exists.
|
||||||
|
if err := os.MkdirAll(filepath.Dir(abs), 0755); err != nil {
|
||||||
|
return fmt.Errorf("cannot create parent directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.WriteFile(abs, []byte(content), 0644); err != nil {
|
||||||
|
return fmt.Errorf("cannot write file: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: saved %d bytes to %s", len(content), abs)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search walks the directory at storagePath and returns files whose names
|
||||||
|
// contain filter (case-insensitive). Results are capped at maxSearchResults.
|
||||||
|
func (fm *FileManager) Search(storagePath string, filter string) (*SearchResponse, error) {
|
||||||
|
rootAbs, rootRel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lowerFilter := strings.ToLower(filter)
|
||||||
|
var results []FileItem
|
||||||
|
|
||||||
|
err = filepath.WalkDir(rootAbs, func(path string, d fs.DirEntry, walkErr error) error {
|
||||||
|
if walkErr != nil {
|
||||||
|
// Skip entries that produce errors (e.g. permission denied) rather than aborting.
|
||||||
|
log.Printf("filemanager: search: skipping %s: %v", path, walkErr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if path == rootAbs {
|
||||||
|
// Skip the root itself.
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: verify every path we touch is within the jail.
|
||||||
|
if err := fm.checkWithinJail(path); err != nil {
|
||||||
|
log.Printf("filemanager: search: path %s escaped jail, skipping", path)
|
||||||
|
return filepath.SkipDir
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(strings.ToLower(d.Name()), lowerFilter) {
|
||||||
|
rel, relErr := filepath.Rel(fm.installDir, path)
|
||||||
|
if relErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
item, buildErr := buildFileItem(d, path, rel)
|
||||||
|
if buildErr != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
results = append(results, item)
|
||||||
|
if len(results) >= maxSearchResults {
|
||||||
|
return io.EOF // Signal early exit; WalkDir treats this as a stop, not an error.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
// WalkDir returns io.EOF only if we injected it; suppress it here.
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return nil, fmt.Errorf("search walk failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = rootRel // rootRel used for Dirname below.
|
||||||
|
return &SearchResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(rootRel),
|
||||||
|
Files: results,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move moves every item in items into destination.
|
||||||
|
// After the move it returns a fresh listing of destination.
|
||||||
|
func (fm *FileManager) Move(storagePath string, items []string, destination string) (*ListResponse, error) {
|
||||||
|
destAbs, destRel, err := fm.parseAndResolve(destination)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure destination directory exists.
|
||||||
|
if err := os.MkdirAll(destAbs, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
itemAbs, _, err := fm.parseAndResolve(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||||
|
}
|
||||||
|
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
|
||||||
|
if err := fm.checkWithinJail(targetAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("move target escapes jail: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.Rename(itemAbs, targetAbs); err != nil {
|
||||||
|
// os.Rename fails across filesystems; fall back to copy+delete.
|
||||||
|
if err2 := copyRecursive(itemAbs, targetAbs); err2 != nil {
|
||||||
|
return nil, fmt.Errorf("failed to move %q: %w", item, err2)
|
||||||
|
}
|
||||||
|
if err2 := os.RemoveAll(itemAbs); err2 != nil {
|
||||||
|
return nil, fmt.Errorf("moved %q but failed to remove source: %w", item, err2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: moved %s -> %s", itemAbs, targetAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(destAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read destination after move: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(destRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy copies every item in items into destination.
|
||||||
|
// After the copy it returns a fresh listing of destination.
|
||||||
|
func (fm *FileManager) Copy(storagePath string, items []string, destination string) (*ListResponse, error) {
|
||||||
|
destAbs, destRel, err := fm.parseAndResolve(destination)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid destination %q: %w", destination, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(destAbs, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
itemAbs, _, err := fm.parseAndResolve(item)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("invalid item path %q: %w", item, err)
|
||||||
|
}
|
||||||
|
targetAbs := filepath.Join(destAbs, filepath.Base(itemAbs))
|
||||||
|
if err := fm.checkWithinJail(targetAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("copy target escapes jail: %w", err)
|
||||||
|
}
|
||||||
|
if err := copyRecursive(itemAbs, targetAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to copy %q: %w", item, err)
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: copied %s -> %s", itemAbs, targetAbs)
|
||||||
|
}
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(destAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot read destination after copy: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, destAbs, destRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(destRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload decodes the base64-encoded content and writes it as filename inside storagePath.
|
||||||
|
// After the write it returns a fresh listing of the parent directory.
|
||||||
|
func (fm *FileManager) Upload(storagePath string, filename string, content []byte) (*ListResponse, error) {
|
||||||
|
if err := validateBareName(filename); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
parentAbs, parentRel, err := fm.parseAndResolve(storagePath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
destAbs := filepath.Join(parentAbs, filename)
|
||||||
|
if err := fm.checkWithinJail(destAbs); err != nil {
|
||||||
|
return nil, fmt.Errorf("upload target escapes jail: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(parentAbs, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot create upload directory: %w", err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(destAbs, content, 0644); err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot write uploaded file: %w", err)
|
||||||
|
}
|
||||||
|
log.Printf("filemanager: uploaded %d bytes to %s", len(content), destAbs)
|
||||||
|
|
||||||
|
entries, err := os.ReadDir(parentAbs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot re-read directory after upload: %w", err)
|
||||||
|
}
|
||||||
|
files, err := buildFileItems(entries, parentAbs, parentRel, fm.installDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &ListResponse{
|
||||||
|
Adapter: adapterName,
|
||||||
|
Storages: []string{storageName},
|
||||||
|
Dirname: toStoragePath(parentRel),
|
||||||
|
Files: files,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Path parsing and security helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// ParsePath converts a VueFinder storage path ("server://relative/path") into
|
||||||
|
// the absolute filesystem path within the jail.
|
||||||
|
//
|
||||||
|
// Rules:
|
||||||
|
// - Must have the form "server://<relative>" where relative may be empty.
|
||||||
|
// - The storage identifier must be "server" — anything else is rejected.
|
||||||
|
// - The resulting absolute path must be within installDir.
|
||||||
|
func (fm *FileManager) ParsePath(storagePath string) (string, error) {
|
||||||
|
abs, _, err := fm.parseAndResolve(storagePath)
|
||||||
|
return abs, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAndResolve parses a storage path and resolves it to an absolute path.
|
||||||
|
// Returns (absolutePath, relativePath, error). relativePath is relative to
|
||||||
|
// installDir and uses the OS path separator.
|
||||||
|
func (fm *FileManager) parseAndResolve(storagePath string) (absPath string, relPath string, err error) {
|
||||||
|
const sep = "://"
|
||||||
|
|
||||||
|
idx := strings.Index(storagePath, sep)
|
||||||
|
if idx < 0 {
|
||||||
|
// Tolerate a bare relative path for internal calls, but flag it.
|
||||||
|
return "", "", fmt.Errorf("invalid storage path %q: missing storage prefix (expected %q://...)", storagePath, storageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
storage := storagePath[:idx]
|
||||||
|
if storage != storageName {
|
||||||
|
return "", "", fmt.Errorf("unknown storage %q: only %q is supported", storage, storageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
relative := storagePath[idx+len(sep):]
|
||||||
|
|
||||||
|
return fm.resolvePath(relative)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolvePath resolves a relative path (which may be empty, representing the
|
||||||
|
// root) to an absolute path inside the jail. It runs filepath.EvalSymlinks on
|
||||||
|
// the resolved path and then re-verifies the prefix to prevent symlink escapes.
|
||||||
|
// Parent-traversal via ".." is neutralised by filepath.Clean before joining.
|
||||||
|
func (fm *FileManager) resolvePath(relativePath string) (absPath string, relPath string, err error) {
|
||||||
|
// filepath.Clean neutralises ".." sequences before we ever join.
|
||||||
|
cleaned := filepath.Clean(relativePath)
|
||||||
|
if cleaned == "." {
|
||||||
|
cleaned = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var absolute string
|
||||||
|
if cleaned == "" {
|
||||||
|
absolute = fm.installDir
|
||||||
|
} else {
|
||||||
|
absolute = filepath.Join(fm.installDir, cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First prefix check — fast path before hitting the filesystem.
|
||||||
|
if !strings.HasPrefix(absolute, fm.installDir+string(filepath.Separator)) && absolute != fm.installDir {
|
||||||
|
return "", "", fmt.Errorf("path %q escapes install directory", relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve symlinks so we can do an authoritative prefix check.
|
||||||
|
resolved, symlinkErr := filepath.EvalSymlinks(absolute)
|
||||||
|
if symlinkErr != nil {
|
||||||
|
// EvalSymlinks fails if the path does not exist yet (e.g. a new file).
|
||||||
|
// In that case fall back to the unresolved absolute path; the subsequent
|
||||||
|
// prefix check is still valid because we already cleaned ".." away.
|
||||||
|
resolved = absolute
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authoritative prefix check on the symlink-resolved path.
|
||||||
|
if !strings.HasPrefix(resolved, fm.installDir+string(filepath.Separator)) && resolved != fm.installDir {
|
||||||
|
return "", "", fmt.Errorf("path %q resolves outside install directory (possible symlink escape)", relativePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute relative portion for use in VueFinder response paths.
|
||||||
|
rel, relErr := filepath.Rel(fm.installDir, resolved)
|
||||||
|
if relErr != nil {
|
||||||
|
rel = cleaned
|
||||||
|
}
|
||||||
|
if rel == "." {
|
||||||
|
rel = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolved, rel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkWithinJail verifies that an absolute path (already joined but not yet
|
||||||
|
// symlink-resolved) stays within the jail. Used for destination paths that may
|
||||||
|
// not exist yet.
|
||||||
|
func (fm *FileManager) checkWithinJail(absPath string) error {
|
||||||
|
clean := filepath.Clean(absPath)
|
||||||
|
if !strings.HasPrefix(clean, fm.installDir+string(filepath.Separator)) && clean != fm.installDir {
|
||||||
|
return fmt.Errorf("path %q is outside the install directory", absPath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// VueFinder path formatting helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// toStoragePath converts a relative path (relative to installDir) to the
|
||||||
|
// "server://..." format expected by VueFinder. An empty relative path maps to
|
||||||
|
// "server://".
|
||||||
|
func toStoragePath(relPath string) string {
|
||||||
|
// Normalise OS separators to forward slashes for the JSON response.
|
||||||
|
fwd := filepath.ToSlash(relPath)
|
||||||
|
return storageName + "://" + fwd
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// FileItem construction helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// buildFileItems builds the slice of FileItem values for a directory listing.
|
||||||
|
func buildFileItems(entries []fs.DirEntry, dirAbs string, dirRel string, installDir string) ([]FileItem, error) {
|
||||||
|
items := make([]FileItem, 0, len(entries))
|
||||||
|
for _, entry := range entries {
|
||||||
|
entryPath := filepath.Join(dirAbs, entry.Name())
|
||||||
|
entryRel := filepath.Join(dirRel, entry.Name())
|
||||||
|
if dirRel == "" {
|
||||||
|
entryRel = entry.Name()
|
||||||
|
}
|
||||||
|
|
||||||
|
item, err := buildFileItem(entry, entryPath, entryRel)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("filemanager: warning: skipping %s: %v", entryPath, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildFileItem builds a single FileItem from a DirEntry.
|
||||||
|
// entryAbs is the absolute path; entryRel is relative to installDir.
|
||||||
|
func buildFileItem(entry fs.DirEntry, entryAbs string, entryRel string) (FileItem, error) {
|
||||||
|
info, err := entry.Info()
|
||||||
|
if err != nil {
|
||||||
|
return FileItem{}, fmt.Errorf("cannot stat %s: %w", entryAbs, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemType := "file"
|
||||||
|
ext := ""
|
||||||
|
size := info.Size()
|
||||||
|
|
||||||
|
if entry.IsDir() {
|
||||||
|
itemType = "dir"
|
||||||
|
size = 0
|
||||||
|
} else {
|
||||||
|
raw := filepath.Ext(entry.Name())
|
||||||
|
if len(raw) > 0 {
|
||||||
|
ext = raw[1:] // strip the leading dot
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalise to forward slashes for the JSON path field.
|
||||||
|
fwdRel := filepath.ToSlash(entryRel)
|
||||||
|
|
||||||
|
return FileItem{
|
||||||
|
Type: itemType,
|
||||||
|
Path: storageName + "://" + fwdRel,
|
||||||
|
Basename: entry.Name(),
|
||||||
|
Extension: ext,
|
||||||
|
Storage: storageName,
|
||||||
|
FileSize: size,
|
||||||
|
LastModified: info.ModTime().Format("2006-01-02 15:04:05"),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Recursive copy helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// copyRecursive copies src to dst. If src is a directory it is copied
|
||||||
|
// recursively. dst must not exist.
|
||||||
|
func copyRecursive(src, dst string) error {
|
||||||
|
srcInfo, err := os.Lstat(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot stat source: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if srcInfo.IsDir() {
|
||||||
|
if err := os.MkdirAll(dst, srcInfo.Mode()); err != nil {
|
||||||
|
return fmt.Errorf("cannot create destination directory: %w", err)
|
||||||
|
}
|
||||||
|
entries, err := os.ReadDir(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot read source directory: %w", err)
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if err := copyRecursive(filepath.Join(src, entry.Name()), filepath.Join(dst, entry.Name())); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular file copy.
|
||||||
|
srcFile, err := os.Open(src)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot open source file: %w", err)
|
||||||
|
}
|
||||||
|
defer srcFile.Close()
|
||||||
|
|
||||||
|
dstFile, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, srcInfo.Mode())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("cannot create destination file: %w", err)
|
||||||
|
}
|
||||||
|
defer dstFile.Close()
|
||||||
|
|
||||||
|
if _, err := io.Copy(dstFile, srcFile); err != nil {
|
||||||
|
return fmt.Errorf("copy failed: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Input validation helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// validateBareName rejects names that are empty, are "." or "..", or contain
|
||||||
|
// path separators. This is used to validate user-supplied filenames for create,
|
||||||
|
// rename, and upload operations.
|
||||||
|
func validateBareName(name string) error {
|
||||||
|
if name == "" {
|
||||||
|
return fmt.Errorf("name must not be empty")
|
||||||
|
}
|
||||||
|
if name == "." || name == ".." {
|
||||||
|
return fmt.Errorf("name %q is not allowed", name)
|
||||||
|
}
|
||||||
|
if strings.ContainsAny(name, "/\\") {
|
||||||
|
return fmt.Errorf("name %q must not contain path separators", name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecodeBase64 is a convenience wrapper used by the handler when the upload
|
||||||
|
// content arrives as a base64 string.
|
||||||
|
func DecodeBase64(encoded string) ([]byte, error) {
|
||||||
|
// Accept both standard and URL-safe base64, with or without padding.
|
||||||
|
decoded, err := base64.StdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
// Try URL-safe encoding as a fallback.
|
||||||
|
decoded, err = base64.URLEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
// Try raw (no padding) variants.
|
||||||
|
decoded, err = base64.RawStdEncoding.DecodeString(encoded)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("cannot base64-decode upload content: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return decoded, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// suppress unused import warning — time is used in buildFileItem via ModTime.Format
|
||||||
|
var _ = time.Now
|
||||||
209
companion-agent/internal/filemanager/handler.go
Normal file
209
companion-agent/internal/filemanager/handler.go
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
package filemanager
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/nats-io/nats.go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HandleNatsRequest is the NATS message handler for the file manager command
|
||||||
|
// subject (corrosion.{license_id}.files.cmd). It deserialises the request,
|
||||||
|
// routes to the correct FileManager operation, and calls msg.Respond with a
|
||||||
|
// NatsResponse JSON payload — either success with data or a structured error.
|
||||||
|
func (fm *FileManager) HandleNatsRequest(msg *nats.Msg) {
|
||||||
|
var req NatsRequest
|
||||||
|
if err := json.Unmarshal(msg.Data, &req); err != nil {
|
||||||
|
log.Printf("filemanager: invalid NATS request payload: %v", err)
|
||||||
|
respondError(msg, "invalid request: "+err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("filemanager: handling %s path=%q", req.Func, req.Path)
|
||||||
|
|
||||||
|
switch req.Func {
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Directory listing
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncList:
|
||||||
|
result, err := fm.List(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Delete
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncDelete:
|
||||||
|
result, err := fm.Delete(req.Path, req.Items)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Rename — req.Name holds the new basename
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncRename:
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
respondError(msg, "rename requires at least one item")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := fm.Rename(req.Path, req.Items[0], req.Name)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Copy
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncCopy:
|
||||||
|
if req.Destination == "" {
|
||||||
|
respondError(msg, "copy requires a destination path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := fm.Copy(req.Path, req.Items, req.Destination)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Move
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncMove:
|
||||||
|
if req.Destination == "" {
|
||||||
|
respondError(msg, "move requires a destination path")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := fm.Move(req.Path, req.Items, req.Destination)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Create directory
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncCreateFolder:
|
||||||
|
if req.Name == "" {
|
||||||
|
respondError(msg, "mkdir requires a folder name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := fm.CreateFolder(req.Path, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Create empty file
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncCreateFile:
|
||||||
|
if req.Name == "" {
|
||||||
|
respondError(msg, "mkfile requires a file name")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := fm.CreateFile(req.Path, req.Name)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Search
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncSearch:
|
||||||
|
result, err := fm.Search(req.Path, req.Filter)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Preview / read file content (VueFinder uses "fm_preview" for text files)
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncPreview:
|
||||||
|
content, err := fm.GetContent(req.Path)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, map[string]string{"content": content})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Save file content
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncSave:
|
||||||
|
if err := fm.SaveContent(req.Path, req.Content); err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, map[string]string{"status": "saved"})
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Upload — content arrives as a base64-encoded string
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
case FuncUpload:
|
||||||
|
if req.Filename == "" {
|
||||||
|
respondError(msg, "upload requires a filename")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
data, err := DecodeBase64(req.Content)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := fm.Upload(req.Path, req.Filename, data)
|
||||||
|
if err != nil {
|
||||||
|
respondError(msg, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondJSON(msg, result)
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// Unknown function
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
default:
|
||||||
|
log.Printf("filemanager: unknown function %q", req.Func)
|
||||||
|
respondError(msg, "unknown function: "+req.Func)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Response helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// respondJSON sends a successful NatsResponse wrapping data.
|
||||||
|
func respondJSON(msg *nats.Msg, data interface{}) {
|
||||||
|
resp := NatsResponse{Success: true, Data: data}
|
||||||
|
bytes, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("filemanager: failed to marshal success response: %v", err)
|
||||||
|
respondError(msg, "internal: failed to marshal response")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := msg.Respond(bytes); err != nil {
|
||||||
|
log.Printf("filemanager: failed to send response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondError sends a failed NatsResponse with the given error message.
|
||||||
|
func respondError(msg *nats.Msg, errMsg string) {
|
||||||
|
resp := NatsResponse{Success: false, Error: errMsg}
|
||||||
|
bytes, _ := json.Marshal(resp)
|
||||||
|
if err := msg.Respond(bytes); err != nil {
|
||||||
|
log.Printf("filemanager: failed to send error response: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
62
companion-agent/internal/filemanager/types.go
Normal file
62
companion-agent/internal/filemanager/types.go
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
package filemanager
|
||||||
|
|
||||||
|
// FileItem represents a file or directory in VueFinder format.
|
||||||
|
type FileItem struct {
|
||||||
|
Type string `json:"type"` // "dir" or "file"
|
||||||
|
Path string `json:"path"` // "server://relative/path"
|
||||||
|
Basename string `json:"basename"` // filename.ext
|
||||||
|
Extension string `json:"extension"` // "ext" or "" for dirs
|
||||||
|
Storage string `json:"storage"` // always "server"
|
||||||
|
FileSize int64 `json:"file_size"` // bytes, 0 for dirs
|
||||||
|
LastModified string `json:"last_modified"` // "2006-01-02 15:04:05"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListResponse is the VueFinder index/directory response.
|
||||||
|
type ListResponse struct {
|
||||||
|
Adapter string `json:"adapter"` // always "local"
|
||||||
|
Storages []string `json:"storages"` // ["server"]
|
||||||
|
Dirname string `json:"dirname"` // "server://current/path"
|
||||||
|
Files []FileItem `json:"files"` // directory contents
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchResponse is the VueFinder search response.
|
||||||
|
type SearchResponse struct {
|
||||||
|
Adapter string `json:"adapter"`
|
||||||
|
Storages []string `json:"storages"`
|
||||||
|
Dirname string `json:"dirname"`
|
||||||
|
Files []FileItem `json:"files"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NatsRequest is the NATS request payload sent by the backend for file manager operations.
|
||||||
|
type NatsRequest struct {
|
||||||
|
Func string `json:"func"` // "fm_list", "fm_delete", etc.
|
||||||
|
Path string `json:"path"` // VueFinder storage path, e.g. "server://cfg"
|
||||||
|
Items []string `json:"items,omitempty"` // items to delete/move/copy (relative storage paths)
|
||||||
|
Name string `json:"name,omitempty"` // new name for rename/create
|
||||||
|
Destination string `json:"destination,omitempty"` // destination storage path for move/copy
|
||||||
|
Filter string `json:"filter,omitempty"` // search filter string
|
||||||
|
Content string `json:"content,omitempty"` // file content (save) or base64-encoded data (upload)
|
||||||
|
Filename string `json:"filename,omitempty"` // original filename for upload
|
||||||
|
}
|
||||||
|
|
||||||
|
// NatsResponse wraps every response sent back to the backend.
|
||||||
|
type NatsResponse struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
Data interface{} `json:"data,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function name constants matching what the backend sends.
|
||||||
|
const (
|
||||||
|
FuncList = "fm_list"
|
||||||
|
FuncDelete = "fm_delete"
|
||||||
|
FuncRename = "fm_rename"
|
||||||
|
FuncCopy = "fm_copy"
|
||||||
|
FuncMove = "fm_move"
|
||||||
|
FuncCreateFolder = "fm_mkdir"
|
||||||
|
FuncCreateFile = "fm_mkfile"
|
||||||
|
FuncSearch = "fm_search"
|
||||||
|
FuncPreview = "fm_preview"
|
||||||
|
FuncSave = "fm_save"
|
||||||
|
FuncUpload = "fm_upload"
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user