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>
736 lines
23 KiB
Go
736 lines
23 KiB
Go
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
|