diff --git a/internal/agent/fs.go b/internal/agent/fs.go index 414d0cc..6a3248f 100644 --- a/internal/agent/fs.go +++ b/internal/agent/fs.go @@ -80,6 +80,10 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) { var a proto.FSChmodArgs _ = json.Unmarshal(env.Args, &a) return fsChmod(a) + case proto.TaskFSCopy: + var a proto.FSCopyArgs + _ = json.Unmarshal(env.Args, &a) + return fsCopy(a) default: return nil, fmt.Errorf("неизвестная операция %q", env.Type) } diff --git a/internal/agent/fscopy.go b/internal/agent/fscopy.go new file mode 100644 index 0000000..cfa20f4 --- /dev/null +++ b/internal/agent/fscopy.go @@ -0,0 +1,166 @@ +package agent + +import ( + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// fsCopy copies or moves Src to Dst, following Conflict when Dst exists. +func fsCopy(a proto.FSCopyArgs) (any, error) { + src, dst := filepath.Clean(a.Src), filepath.Clean(a.Dst) + if a.Src == "" || a.Dst == "" { + return nil, errors.New("не указан путь") + } + if src == dst { + return nil, errors.New("откуда и куда - одно и то же") + } + if strings.HasPrefix(dst, src+string(filepath.Separator)) { + return nil, errors.New("нельзя положить папку внутрь неё самой") + } + if _, err := os.Lstat(src); err != nil { + return nil, err + } + if _, err := os.Stat(filepath.Dir(dst)); err != nil { + return nil, fmt.Errorf("нет папки %s", filepath.Dir(dst)) + } + c := &copier{move: a.Move, conflict: a.Conflict} + if err := c.place(src, dst); err != nil { + return map[string]int{"done": c.done, "skipped": c.skipped}, err + } + return map[string]int{"done": c.done, "skipped": c.skipped}, nil +} + +type copier struct { + move bool + conflict string + done, skipped int +} + +// place puts src at dst. Two folders merge when the rule allows it; anything +// else that is in the way stops, is replaced or is kept, by the rule. +func (c *copier) place(src, dst string) error { + si, err := os.Lstat(src) + if err != nil { + return err + } + di, err := os.Lstat(dst) + if err == nil { + if c.conflict != "replace" && c.conflict != "skip" { + return fmt.Errorf("%s уже существует", dst) + } + if si.IsDir() && di.IsDir() { + entries, err := os.ReadDir(src) + if err != nil { + return err + } + for _, e := range entries { + if err := c.place(filepath.Join(src, e.Name()), filepath.Join(dst, e.Name())); err != nil { + return err + } + } + if c.move { + // Whatever was skipped stays where it was. + _ = os.Remove(src) + } + return nil + } + if c.conflict == "skip" { + c.skipped++ + return nil + } + if err := os.RemoveAll(dst); err != nil { + return err + } + } else if !errors.Is(err, fs.ErrNotExist) { + return err + } + if c.move { + err := os.Rename(src, dst) + if err == nil { + c.done++ + return nil + } + // Another file system: copy, then take the original away. + if !errors.Is(err, syscall.EXDEV) { + return err + } + if err := c.copyTree(src, dst); err != nil { + return err + } + return os.RemoveAll(src) + } + return c.copyTree(src, dst) +} + +// copyTree copies a file, a symlink or a whole folder, keeping modes and +// times. +func (c *copier) copyTree(src, dst string) error { + return filepath.WalkDir(src, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, _ := filepath.Rel(src, p) + to := filepath.Join(dst, rel) + info, err := os.Lstat(p) + if err != nil { + return err + } + switch { + case info.Mode()&fs.ModeSymlink != 0: + link, err := os.Readlink(p) + if err != nil { + return err + } + if err := os.Symlink(link, to); err != nil { + return err + } + case info.IsDir(): + if err := os.Mkdir(to, info.Mode().Perm()); err != nil && !errors.Is(err, fs.ErrExist) { + return err + } + case info.Mode().IsRegular(): + if err := copyFile(p, to, info); err != nil { + return err + } + default: + return nil // sockets, devices: not copied + } + c.done++ + if st, ok := info.Sys().(*syscall.Stat_t); ok { + _ = os.Lchown(to, int(st.Uid), int(st.Gid)) + } + if info.Mode()&fs.ModeSymlink == 0 { + _ = os.Chtimes(to, info.ModTime(), info.ModTime()) + } + return nil + }) +} + +func copyFile(src, dst string, info fs.FileInfo) error { + in, err := os.Open(src) + if err != nil { + return err + } + defer in.Close() + out, err := os.OpenFile(dst, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, info.Mode().Perm()) + if err != nil { + return err + } + if _, err := io.Copy(out, in); err != nil { + out.Close() + os.Remove(dst) + return err + } + if err := out.Close(); err != nil { + return err + } + return os.Chmod(dst, info.Mode()&(fs.ModePerm|fs.ModeSetuid|fs.ModeSetgid|fs.ModeSticky)) +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index af2d7e0..581a154 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -87,6 +87,7 @@ const ( TaskFSDownload = "fs.download" // read a whole file (larger cap) for browser download TaskFSStat = "fs.stat" // a file's properties: type, size, mode, owner TaskFSChmod = "fs.chmod" // change mode and owner, of a folder's contents too + TaskFSCopy = "fs.copy" // copy or move, with a rule for what is already there TaskFSPut = "fs.put" // write a file the browser sends in pieces TaskFSSend = "fs.send" // send a file to the browser in pieces @@ -178,6 +179,17 @@ type FSChmodArgs struct { Only string `json:"only,omitempty"` } +// FSCopyArgs are the arguments of TaskFSCopy: Src goes to Dst (the full new +// path), copied or, with Move, moved. Conflict says what to do when Dst is +// already there: "error" (the default) stops, "replace" puts Src in its place, +// "skip" keeps it. Folders on both sides are merged under replace and skip. +type FSCopyArgs struct { + Src string `json:"src"` + Dst string `json:"dst"` + Move bool `json:"move,omitempty"` + Conflict string `json:"conflict,omitempty"` +} + // FSEntry is one directory entry. type FSEntry struct { Name string `json:"name"` diff --git a/internal/server/handlers_panel_fs.go b/internal/server/handlers_panel_fs.go index 3f4b1ef..084c286 100644 --- a/internal/server/handlers_panel_fs.go +++ b/internal/server/handlers_panel_fs.go @@ -21,6 +21,7 @@ var fsOps = map[string]string{ "move": proto.TaskFSMove, "stat": proto.TaskFSStat, "chmod": proto.TaskFSChmod, + "copy": proto.TaskFSCopy, } // fsTaskTimeout bounds how long we wait for an agent to answer a file op. diff --git a/internal/server/web/templates/files.html b/internal/server/web/templates/files.html index 72b8d6f..2dcb9d1 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -92,6 +92,28 @@ /* Square icon buttons, the height of the buttons beside them. */ .btn.sq { width:28px; height:28px; padding:0; display:inline-flex; align-items:center; justify-content:center; } .fm-bar .btn.sq { width:34px; } + /* Move or copy: the action, where to with suggestions, the name, and what + to do with what is already there. */ + #mvdlg { max-width:560px; } + #mvdlg .dlg-b { padding:14px 18px 16px; } + .mv label { margin:12px 0 4px; } + .mv .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; } + .mv .pp-act { display:flex; justify-content:flex-end; margin-top:16px; } + .mv input[type=text], .mv select { width:100%; height:36px; padding:6px 10px; } + .mv select { padding-right:36px; } + .seg { display:inline-flex; margin-top:12px; border:1px solid var(--border); border-radius:8px; overflow:hidden; } + .seg button { background:transparent; color:var(--muted); border:0; border-radius:0; padding:7px 16px; font-size:14px; } + .seg button + button { border-left:1px solid var(--border); } + .seg button.on { background:#12202f; color:var(--text); box-shadow:inset 0 -2px 0 var(--accent); } + .seg button:hover { background:#151a21; color:var(--text); } + .mv-dirbox { position:relative; } + .mv-dirbox input { font-family:ui-monospace,Menlo,monospace; font-size:13px; } + .mv-sug { position:absolute; left:0; right:0; top:calc(100% + 4px); z-index:5; max-height:220px; overflow:auto; + margin:0; padding:4px; list-style:none; background:var(--panel); border:1px solid var(--border); border-radius:8px; + box-shadow:0 8px 24px rgba(0,0,0,.45); font-family:ui-monospace,Menlo,monospace; font-size:13px; } + .mv-sug li { padding:5px 8px; border-radius:5px; cursor:pointer; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; } + .mv-sug li.sel, .mv-sug li:hover { background:#12202f; color:var(--text); } + .mv-sug li .muted { color:var(--muted); } /* Properties: owner, group and mode, like FileZilla's. */ #propdlg { max-width:520px; } #propdlg .dlg-b { padding:14px 18px 16px; } @@ -312,6 +334,34 @@
+ +