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://" 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