diff --git a/agent/app/service/file.go b/agent/app/service/file.go index 45af324ec339..d5caacf17e60 100644 --- a/agent/app/service/file.go +++ b/agent/app/service/file.go @@ -230,8 +230,12 @@ func (f *FileService) buildChildNode(childNode *response.FileTree, fileInfo *fil return f.buildFileTree(childNode, subInfo.Items, op, level-1) } +func hasInvalidFileName(fullPath string) bool { + return files.IsInvalidChar(filepath.Base(fullPath)) +} + func (f *FileService) Create(op request.FileCreate) error { - if files.IsInvalidChar(op.Path) { + if hasInvalidFileName(op.Path) { return buserr.New("ErrInvalidChar") } fo := files.NewFileOp() @@ -301,6 +305,9 @@ func (f *FileService) Delete(op request.FileDelete) error { if err != nil { return err } + if err := cleanupTrashInfoByEntryPath(op.Path); err != nil { + global.LOG.Warnf("cleanup trashinfo failed for %s: %v", op.Path, err) + } f.cleanupPermanentDeleteHistory(historyTargets) return nil } @@ -330,6 +337,9 @@ func (f *FileService) BatchDelete(op request.FileBatchDelete) error { if err := fo.DeleteDir(file); err != nil { return err } + if err := cleanupTrashInfoByEntryPath(file); err != nil { + global.LOG.Warnf("cleanup trashinfo failed for %s: %v", file, err) + } f.cleanupPermanentDeleteHistory(targets) } } else { @@ -337,6 +347,9 @@ func (f *FileService) BatchDelete(op request.FileBatchDelete) error { if err := fo.DeleteFile(file); err != nil { return err } + if err := cleanupTrashInfoByEntryPath(file); err != nil { + global.LOG.Warnf("cleanup trashinfo failed for %s: %v", file, err) + } f.cleanupPermanentDeleteHistory([]string{file}) } } @@ -560,7 +573,7 @@ func (f *FileService) SaveContent(edit request.FileEdit) error { } func (f *FileService) ChangeName(req request.FileRename) error { - if files.IsInvalidChar(req.NewName) { + if hasInvalidFileName(req.NewName) { return buserr.New("ErrInvalidChar") } fo := files.NewFileOp() diff --git a/agent/app/service/recycle_bin.go b/agent/app/service/recycle_bin.go index 9af3559e293c..109dfbeaca8d 100644 --- a/agent/app/service/recycle_bin.go +++ b/agent/app/service/recycle_bin.go @@ -1,8 +1,10 @@ package service import ( + "bytes" "fmt" "math" + "net/url" "os" "path" "strconv" @@ -15,9 +17,20 @@ import ( "github.com/1Panel-dev/1Panel/agent/buserr" "github.com/1Panel-dev/1Panel/agent/constant" "github.com/1Panel-dev/1Panel/agent/global" + "github.com/1Panel-dev/1Panel/agent/utils/common" "github.com/1Panel-dev/1Panel/agent/utils/files" "github.com/1Panel-dev/1Panel/agent/utils/re" "github.com/shirou/gopsutil/v4/disk" + "gopkg.in/ini.v1" +) + +const ( + recycleBinClashDir = ".1panel_clash" + recycleBinFilesSubdir = "files" + recycleBinInfoSubdir = "info" + trashInfoSuffix = ".trashinfo" + trashInfoSection = "Trash Info" + trashInfoTimeLayout = "2006-01-02T15:04:05" ) type RecycleBinService struct { @@ -35,33 +48,19 @@ func NewIRecycleBinService() IRecycleBinService { } func (r RecycleBinService) Page(search dto.PageInfo) (int64, []response.RecycleBinDTO, error) { - var ( - result []response.RecycleBinDTO - ) + var result []response.RecycleBinDTO partitions, err := disk.Partitions(false) if err != nil { return 0, nil, err } op := files.NewFileOp() for _, p := range partitions { - dir := path.Join(p.Mountpoint, ".1panel_clash") - if !op.Stat(dir) { + clashRoot := path.Join(p.Mountpoint, recycleBinClashDir) + if !op.Stat(clashRoot) { continue } - clashFiles, err := os.ReadDir(dir) - if err != nil { - return 0, nil, err - } - for _, file := range clashFiles { - if strings.HasPrefix(file.Name(), "_1p_") { - recycleDTO, err := getRecycleBinDTOFromName(file.Name()) - recycleDTO.IsDir = file.IsDir() - recycleDTO.From = dir - if err == nil { - result = append(result, *recycleDTO) - } - } - } + result = append(result, collectTrashEntries(clashRoot)...) + result = append(result, collectLegacyEntries(clashRoot)...) } startIndex := (search.Page - 1) * search.PageSize endIndex := startIndex + search.PageSize @@ -83,34 +82,55 @@ func (r RecycleBinService) Create(create request.RecycleBinCreate) error { if !op.Stat(create.SourcePath) { return buserr.New("ErrLinkPathNotFound") } - clashDir, err := getClashDir(create.SourcePath) + clashRoot, err := getClashDir(create.SourcePath) + if err != nil { + return err + } + filesDir, infoDir, err := ensureTrashDirs(clashRoot) if err != nil { return err } - paths := strings.Split(create.SourcePath, "/") - rNamePre := strings.Join(paths, "_1p_") + deleteTime := time.Now() openFile, err := op.OpenFile(create.SourcePath) if err != nil { return err } fileInfo, err := openFile.Stat() + _ = openFile.Close() if err != nil { return err } - size := 0 + size := int64(0) if fileInfo.IsDir() { sizeF, err := op.GetDirSize(create.SourcePath) if err != nil { return err } - size = int(sizeF) + size = int64(sizeF) } else { - size = int(fileInfo.Size()) + size = fileInfo.Size() } - rName := fmt.Sprintf("_1p_%s%s_p_%d_%d", "file", rNamePre, size, deleteTime.Unix()) - return op.Mv(create.SourcePath, path.Join(clashDir, rName)) + rName := allocateTrashEntryName(filesDir, infoDir, create.SourcePath) + info := trashInfo{ + Path: create.SourcePath, + DeletionDate: deleteTime, + Size: size, + IsDir: fileInfo.IsDir(), + } + infoPath := path.Join(infoDir, rName+trashInfoSuffix) + if err := writeTrashInfo(infoPath, info); err != nil { + return err + } + targetPath := path.Join(filesDir, rName) + if err := op.Mv(create.SourcePath, targetPath); err != nil { + if rmErr := os.Remove(infoPath); rmErr != nil && !os.IsNotExist(rmErr) { + global.LOG.Warnf("rollback trashinfo failed for %s: %v", infoPath, rmErr) + } + return err + } + return nil } func (r RecycleBinService) Reduce(reduce request.RecycleBinReduce) error { @@ -119,10 +139,13 @@ func (r RecycleBinService) Reduce(reduce request.RecycleBinReduce) error { if !op.Stat(filePath) { return buserr.New("ErrLinkPathNotFound") } - recycleBinDTO, err := getRecycleBinDTOFromName(reduce.RName) + recycleBinDTO, err := loadRecycleBinDTO(reduce.From, reduce.RName) if err != nil { return err } + if recycleBinDTO.SourcePath == "" { + return buserr.New("ErrSourcePathNotFound") + } if !op.Stat(path.Dir(recycleBinDTO.SourcePath)) { return buserr.New("ErrSourcePathNotFound") } @@ -131,7 +154,13 @@ func (r RecycleBinService) Reduce(reduce request.RecycleBinReduce) error { return err } } - return op.Mv(filePath, recycleBinDTO.SourcePath) + if err := op.Mv(filePath, recycleBinDTO.SourcePath); err != nil { + return err + } + if err := cleanupTrashInfoByEntryPath(filePath); err != nil { + global.LOG.Warnf("cleanup trashinfo failed for %s: %v", filePath, err) + } + return nil } func (r RecycleBinService) Clear() error { @@ -141,7 +170,7 @@ func (r RecycleBinService) Clear() error { } op := files.NewFileOp() for _, p := range partitions { - dir := path.Join(p.Mountpoint, ".1panel_clash") + dir := path.Join(p.Mountpoint, recycleBinClashDir) if !op.Stat(dir) { continue } @@ -149,9 +178,14 @@ func (r RecycleBinService) Clear() error { if err := op.Mv(dir, newDir); err != nil { return err } - go func() { - _ = op.DeleteDir(newDir) - }() + go func(target string) { + defer func() { + if r := recover(); r != nil { + global.LOG.Warnf("clear recycle bin panic on %s: %v", target, r) + } + }() + _ = op.DeleteDir(target) + }(newDir) } return nil } @@ -166,8 +200,8 @@ func getClashDir(realPath string) (string, error) { continue } if strings.HasPrefix(realPath, p.Mountpoint) { - clashDir := path.Join(p.Mountpoint, ".1panel_clash") - if err = createClashDir(path.Join(p.Mountpoint, ".1panel_clash")); err != nil { + clashDir := path.Join(p.Mountpoint, recycleBinClashDir) + if err = createClashDir(clashDir); err != nil { return "", err } return clashDir, nil @@ -186,6 +220,264 @@ func createClashDir(clashDir string) error { return nil } +func ensureTrashDirs(clashRoot string) (string, string, error) { + filesDir := path.Join(clashRoot, recycleBinFilesSubdir) + infoDir := path.Join(clashRoot, recycleBinInfoSubdir) + op := files.NewFileOp() + if !op.Stat(filesDir) { + if err := op.CreateDir(filesDir, constant.DirPerm); err != nil { + return "", "", err + } + } + if !op.Stat(infoDir) { + if err := op.CreateDir(infoDir, constant.DirPerm); err != nil { + return "", "", err + } + } + return filesDir, infoDir, nil +} + +func trashFilesDir(clashRoot string) string { + return path.Join(clashRoot, recycleBinFilesSubdir) +} + +func trashInfoDir(clashRoot string) string { + return path.Join(clashRoot, recycleBinInfoSubdir) +} + +func trashInfoPath(infoDir, entryName string) string { + return path.Join(infoDir, entryName+trashInfoSuffix) +} + +type trashInfo struct { + Path string + DeletionDate time.Time + Size int64 + IsDir bool +} + +func writeTrashInfo(dstPath string, info trashInfo) error { + cfg := ini.Empty() + section, err := cfg.NewSection(trashInfoSection) + if err != nil { + return err + } + if _, err := section.NewKey("Path", url.PathEscape(info.Path)); err != nil { + return err + } + if _, err := section.NewKey("DeletionDate", info.DeletionDate.Format(trashInfoTimeLayout)); err != nil { + return err + } + if _, err := section.NewKey("Size", strconv.FormatInt(info.Size, 10)); err != nil { + return err + } + if _, err := section.NewKey("IsDir", strconv.FormatBool(info.IsDir)); err != nil { + return err + } + + var buf bytes.Buffer + if _, err := cfg.WriteTo(&buf); err != nil { + return err + } + tmp := dstPath + ".tmp" + if err := os.WriteFile(tmp, buf.Bytes(), constant.FilePerm); err != nil { + return err + } + return os.Rename(tmp, dstPath) +} + +func readTrashInfo(srcPath string) (*trashInfo, error) { + cfg, err := ini.Load(srcPath) + if err != nil { + return nil, err + } + section, err := cfg.GetSection(trashInfoSection) + if err != nil { + return nil, err + } + rawPath := section.Key("Path").Value() + decodedPath, err := url.PathUnescape(rawPath) + if err != nil { + decodedPath = rawPath + } + info := &trashInfo{ + Path: decodedPath, + IsDir: section.Key("IsDir").MustBool(false), + } + if deletionStr := section.Key("DeletionDate").Value(); deletionStr != "" { + if t, parseErr := time.ParseInLocation(trashInfoTimeLayout, deletionStr, time.Local); parseErr == nil { + info.DeletionDate = t + } + } + if sizeStr := section.Key("Size").Value(); sizeStr != "" { + if sz, sizeErr := strconv.ParseInt(sizeStr, 10, 64); sizeErr == nil { + info.Size = sz + } + } + return info, nil +} + +func collectTrashEntries(clashRoot string) []response.RecycleBinDTO { + filesDir := trashFilesDir(clashRoot) + infoDir := trashInfoDir(clashRoot) + op := files.NewFileOp() + if !op.Stat(filesDir) { + return nil + } + entries, err := os.ReadDir(filesDir) + if err != nil { + global.LOG.Warnf("read recycle files dir %s failed: %v", filesDir, err) + return nil + } + var result []response.RecycleBinDTO + for _, entry := range entries { + dto, err := buildTrashDTO(filesDir, infoDir, entry) + if err != nil { + global.LOG.Warnf("build recycle dto for %s failed: %v", entry.Name(), err) + continue + } + result = append(result, *dto) + } + return result +} + +func collectLegacyEntries(clashRoot string) []response.RecycleBinDTO { + op := files.NewFileOp() + if !op.Stat(clashRoot) { + return nil + } + entries, err := os.ReadDir(clashRoot) + if err != nil { + return nil + } + var result []response.RecycleBinDTO + for _, entry := range entries { + name := entry.Name() + if name == recycleBinFilesSubdir || name == recycleBinInfoSubdir { + continue + } + if !strings.HasPrefix(name, "_1p_") { + continue + } + dto, err := getRecycleBinDTOFromName(name) + if err != nil { + continue + } + dto.IsDir = entry.IsDir() + dto.From = clashRoot + result = append(result, *dto) + } + return result +} + +func buildTrashDTO(filesDir, infoDir string, entry os.DirEntry) (*response.RecycleBinDTO, error) { + entryName := entry.Name() + infoPath := trashInfoPath(infoDir, entryName) + if info, err := readTrashInfo(infoPath); err == nil { + return trashInfoToDTO(entryName, filesDir, info), nil + } else if !os.IsNotExist(err) { + global.LOG.Warnf("read trashinfo %s failed: %v", infoPath, err) + } + + entryPath := path.Join(filesDir, entryName) + fi, err := os.Stat(entryPath) + if err != nil { + return nil, err + } + size := fi.Size() + if fi.IsDir() { + if sz, sizeErr := files.NewFileOp().GetDirSize(entryPath); sizeErr == nil { + size = int64(sz) + } + } + return &response.RecycleBinDTO{ + Name: entryName, + Size: clampToInt(size), + Type: "file", + DeleteTime: fi.ModTime(), + RName: entryName, + SourcePath: "", + IsDir: fi.IsDir(), + From: filesDir, + }, nil +} + +func trashInfoToDTO(entryName, filesDir string, info *trashInfo) *response.RecycleBinDTO { + return &response.RecycleBinDTO{ + Name: path.Base(info.Path), + Size: clampToInt(info.Size), + Type: "file", + DeleteTime: info.DeletionDate, + RName: entryName, + SourcePath: info.Path, + IsDir: info.IsDir, + From: filesDir, + } +} + +func loadRecycleBinDTO(from, name string) (*response.RecycleBinDTO, error) { + if path.Base(from) == recycleBinFilesSubdir { + infoDir := trashInfoDir(path.Dir(from)) + info, err := readTrashInfo(trashInfoPath(infoDir, name)) + if err == nil { + return trashInfoToDTO(name, from, info), nil + } + if !os.IsNotExist(err) { + global.LOG.Warnf("read trashinfo for %s failed: %v", name, err) + } + } + + dto, err := getRecycleBinDTOFromName(name) + if err != nil { + return nil, err + } + dto.From = from + return dto, nil +} + +func cleanupTrashInfoByEntryPath(filePath string) error { + cleaned := path.Clean(filePath) + parent := path.Dir(cleaned) + if path.Base(parent) != recycleBinFilesSubdir { + return nil + } + clashRoot := path.Dir(parent) + if path.Base(clashRoot) != recycleBinClashDir { + return nil + } + infoPath := trashInfoPath(trashInfoDir(clashRoot), path.Base(cleaned)) + if err := os.Remove(infoPath); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} + +func allocateTrashEntryName(filesDir, infoDir, sourcePath string) string { + ext := path.Ext(path.Base(sourcePath)) + op := files.NewFileOp() + for i := 0; i < 5; i++ { + name := strings.ReplaceAll(common.GetUuid(), "-", "") + ext + if op.Stat(path.Join(filesDir, name)) { + continue + } + if op.Stat(trashInfoPath(infoDir, name)) { + continue + } + return name + } + return fmt.Sprintf("%s-%d%s", strings.ReplaceAll(common.GetUuid(), "-", ""), time.Now().UnixNano(), ext) +} + +func clampToInt(v int64) int { + if v > math.MaxInt { + return math.MaxInt + } + if v < math.MinInt { + return math.MinInt + } + return int(v) +} + func getRecycleBinDTOFromName(filename string) (*response.RecycleBinDTO, error) { matches := re.GetRegex(re.RecycleBinFilePattern).FindStringSubmatch(filename) if len(matches) != 4 {