mirror of
https://github.com/truewhile/MeBox.git
synced 2026-09-29 03:26:37 +08:00
44ca451cd4
优化
263 lines
7.1 KiB
Go
263 lines
7.1 KiB
Go
// Package service — server-side file browser and safe local file operations.
|
|
//
|
|
// FileManagerService exposes a strict, allow-listed view of the server's
|
|
// filesystem. The design follows a StorageChain boundary: callers
|
|
// work with file-item-like records, while this service owns path validation,
|
|
// local storage operations, and mutation safety.
|
|
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
|
|
"go.uber.org/zap"
|
|
|
|
"github.com/ShukeBta/MMTL/internal/config"
|
|
"github.com/ShukeBta/MMTL/internal/repository"
|
|
)
|
|
|
|
// FileManagerService browses and mutates the server-side filesystem.
|
|
type FileManagerService struct {
|
|
cfg *config.Config
|
|
log *zap.Logger
|
|
repo *repository.Container
|
|
}
|
|
|
|
// NewFileManagerService is the constructor.
|
|
func NewFileManagerService(cfg *config.Config, log *zap.Logger, repo *repository.Container) *FileManagerService {
|
|
return &FileManagerService{cfg: cfg, log: log, repo: repo}
|
|
}
|
|
|
|
// Entry is one file or directory shown in the browser.
|
|
type Entry struct {
|
|
Name string `json:"name"`
|
|
Path string `json:"path"`
|
|
IsDir bool `json:"is_dir"`
|
|
Size int64 `json:"size"`
|
|
Modified int64 `json:"modified"`
|
|
Ext string `json:"ext,omitempty"`
|
|
}
|
|
|
|
// Listing describes the contents of a directory plus navigation hints.
|
|
type Listing struct {
|
|
Path string `json:"path"`
|
|
Parent string `json:"parent,omitempty"`
|
|
Roots []Root `json:"roots,omitempty"`
|
|
Entries []Entry `json:"entries"`
|
|
}
|
|
|
|
// Root is the entry-point label shown when no path is given.
|
|
type Root struct {
|
|
Label string `json:"label"`
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
type FileOperationResult struct {
|
|
Path string `json:"path"`
|
|
}
|
|
|
|
// ErrPathOutOfBounds is returned when path falls outside every allowed root.
|
|
var ErrPathOutOfBounds = errors.New("path is outside the allowed roots")
|
|
|
|
// ErrRootMutation protects configured roots such as /media and /downloads.
|
|
var ErrRootMutation = errors.New("refusing to mutate an allowed root")
|
|
|
|
// List enumerates a directory under one of the allowed roots, returning up to
|
|
// maxEntries items sorted by (dir-first, path). Recursive listing is capped by
|
|
// maxEntries to avoid accidentally walking huge NAS trees from the UI.
|
|
func (s *FileManagerService) List(path string, maxEntries int, recursive ...bool) (*Listing, error) {
|
|
if maxEntries <= 0 || maxEntries > 5000 {
|
|
maxEntries = 1000
|
|
}
|
|
roots, rootList, err := s.allowedRootList()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return &Listing{Path: "", Roots: rootList}, nil
|
|
}
|
|
|
|
abs, err := filepath.Abs(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !s.withinAllowed(abs, roots) {
|
|
return nil, ErrPathOutOfBounds
|
|
}
|
|
info, err := os.Stat(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !info.IsDir() {
|
|
return &Listing{Path: abs, Roots: rootList, Entries: []Entry{s.entryFromInfo(abs, info)}}, nil
|
|
}
|
|
|
|
out := &Listing{Path: abs, Roots: rootList, Entries: []Entry{}}
|
|
parent := filepath.Dir(abs)
|
|
if parent != abs && s.withinAllowed(parent, roots) {
|
|
out.Parent = parent
|
|
}
|
|
if len(recursive) > 0 && recursive[0] {
|
|
if err := s.walkEntries(abs, maxEntries, out); err != nil {
|
|
return nil, err
|
|
}
|
|
sortFileEntries(out.Entries)
|
|
return out, nil
|
|
}
|
|
|
|
entries, err := os.ReadDir(abs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
for _, e := range entries {
|
|
if len(out.Entries) >= maxEntries {
|
|
break
|
|
}
|
|
if strings.HasPrefix(e.Name(), ".") {
|
|
continue
|
|
}
|
|
full := filepath.Join(abs, e.Name())
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
out.Entries = append(out.Entries, s.entryFromInfo(full, info))
|
|
}
|
|
sortFileEntries(out.Entries)
|
|
return out, nil
|
|
}
|
|
|
|
func (s *FileManagerService) CreateFolder(parent, name string) (*FileOperationResult, error) {
|
|
parentPath, roots, err := s.requireAllowedPath(parent, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cleanName := sanitizeFilename(name)
|
|
if strings.TrimSpace(name) == "" || cleanName == "" || strings.ContainsAny(name, `/\`) {
|
|
return nil, errors.New("invalid folder name")
|
|
}
|
|
dst := filepath.Join(parentPath, cleanName)
|
|
if !s.withinAllowed(dst, roots) {
|
|
return nil, ErrPathOutOfBounds
|
|
}
|
|
if err := os.MkdirAll(dst, 0o755); err != nil { // #nosec G301 -- user-created media directories must remain readable by NAS/player users.
|
|
return nil, err
|
|
}
|
|
return &FileOperationResult{Path: dst}, nil
|
|
}
|
|
|
|
func (s *FileManagerService) Rename(path, name string) (*FileOperationResult, error) {
|
|
src, roots, err := s.requireAllowedPath(path, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cleanName := sanitizeFilename(name)
|
|
if strings.TrimSpace(name) == "" || cleanName == "" || strings.ContainsAny(name, `/\`) {
|
|
return nil, errors.New("invalid name")
|
|
}
|
|
dst := filepath.Join(filepath.Dir(src), cleanName)
|
|
if !s.withinAllowed(dst, roots) {
|
|
return nil, ErrPathOutOfBounds
|
|
}
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return nil, fmt.Errorf("target already exists: %s", dst)
|
|
}
|
|
if err := os.Rename(src, dst); err != nil {
|
|
return nil, err
|
|
}
|
|
return &FileOperationResult{Path: dst}, nil
|
|
}
|
|
|
|
func (s *FileManagerService) Delete(path string) error {
|
|
target, _, err := s.requireAllowedPath(path, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return os.RemoveAll(target)
|
|
}
|
|
|
|
func (s *FileManagerService) Transfer(sourcePath, destDir string, mode TransferMode) (*FileOperationResult, error) {
|
|
src, roots, err := s.requireAllowedPath(sourcePath, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dstDir, _, err := s.requireAllowedPath(destDir, false)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
info, err := os.Stat(src)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dst := filepath.Join(dstDir, filepath.Base(src))
|
|
if !s.withinAllowed(dst, roots) {
|
|
return nil, ErrPathOutOfBounds
|
|
}
|
|
if _, err := os.Stat(dst); err == nil {
|
|
return nil, fmt.Errorf("target already exists: %s", dst)
|
|
}
|
|
if mode == "" {
|
|
mode = TransferCopy
|
|
}
|
|
if info.IsDir() {
|
|
if err := transferDirectory(src, dst, mode); err != nil {
|
|
return nil, err
|
|
}
|
|
} else if err := transferFile(src, dst, mode); err != nil {
|
|
return nil, err
|
|
}
|
|
return &FileOperationResult{Path: dst}, nil
|
|
}
|
|
|
|
func (s *FileManagerService) walkEntries(root string, maxEntries int, out *Listing) error {
|
|
count := 0
|
|
return filepath.WalkDir(root, func(full string, d os.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if full == root {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(d.Name(), ".") {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if count >= maxEntries {
|
|
return filepath.SkipAll
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
out.Entries = append(out.Entries, s.entryFromInfo(full, info))
|
|
count++
|
|
return nil
|
|
})
|
|
}
|
|
|
|
func (s *FileManagerService) entryFromInfo(path string, info os.FileInfo) Entry {
|
|
return Entry{
|
|
Name: filepath.Base(path),
|
|
Path: path,
|
|
IsDir: info.IsDir(),
|
|
Size: info.Size(),
|
|
Modified: info.ModTime().Unix(),
|
|
Ext: strings.TrimPrefix(strings.ToLower(filepath.Ext(path)), "."),
|
|
}
|
|
}
|
|
|
|
func sortFileEntries(entries []Entry) {
|
|
sort.Slice(entries, func(i, j int) bool {
|
|
if entries[i].IsDir != entries[j].IsDir {
|
|
return entries[i].IsDir
|
|
}
|
|
return strings.ToLower(entries[i].Path) < strings.ToLower(entries[j].Path)
|
|
})
|
|
}
|