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 @@
+ +
+ Переместить или копировать + +
+
+
+
+ + +
+ +
+ + +
+ + + + +
+
+
+
Свойства @@ -458,7 +508,7 @@ '
' + (isZip?'':'') + (e.is_dir?'':''+ICON_DL+'') + - '' + + '' + '' + '
'; const nm = tr.querySelector('.nm'); @@ -1087,12 +1137,110 @@ } } - async function move(e) { - const dst = prompt('Переместить / переименовать в (полный путь):', join(cwd, e.name)); - if (!dst) return; - const r = await api('move', {src: join(cwd, e.name), dst}); - r.ok ? (toast('перемещено'), load(cwd)) : toast(r.error||'ошибка', true); + // ---------- Move or copy ---------- + const M = { src: '', op: 'move', sug: [], sel: -1, seq: 0 }; + const dirCache = new Map(); // folder -> {at, names} of its subfolders + // Lists a folder for the suggestions, quietly: no spinner, kept a little. + async function subdirs(path) { + const c = dirCache.get(path); + if (c && Date.now() - c.at < 30000) return c.names; + try { + const r = await fetch('/api/panel/agents/' + encodeURIComponent(agentID) + '/fs/list', { + method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify({path}) + }).then(r => r.json()); + const names = r.ok ? (r.data.entries || []).filter(e => e.is_dir).map(e => e.name).sort(byNameStr) : []; + dirCache.set(path, {at: Date.now(), names}); + return names; + } catch (e) { return []; } } + const byNameStr = (a, b) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'base'}); + function setOp(op) { + M.op = op; + document.querySelectorAll('#mv-op button').forEach(b => { b.classList.toggle('on', b.dataset.op === op); b.setAttribute('aria-checked', b.dataset.op === op); }); + $('mv-go').textContent = op === 'move' ? 'Переместить' : 'Копировать'; + } + document.querySelectorAll('#mv-op button').forEach(b => b.onclick = () => setOp(b.dataset.op)); + // Suggestions: the subfolders of what is typed, matching its last part. + async function suggest() { + const v = $('mv-dir').value, seq = ++M.seq; + const i = v.lastIndexOf('/'); + const parent = i <= 0 ? '/' : v.slice(0, i), pre = v.slice(i + 1).toLowerCase(); + const names = await subdirs(parent); + if (seq !== M.seq) return; + M.sug = names.filter(n => n.toLowerCase().startsWith(pre)).slice(0, 60) + .map(n => (parent === '/' ? '' : parent) + '/' + n + '/'); + M.sel = -1; + showSug(); + } + function showSug() { + const ul = $('mv-sug'); + ul.innerHTML = ''; + M.sug.forEach((full, k) => { + const li = document.createElement('li'); + const cut = full.slice(0, -1).lastIndexOf('/') + 1; + li.innerHTML = ''; + li.firstChild.textContent = full.slice(0, cut); + li.lastChild.textContent = full.slice(cut); + if (k === M.sel) li.className = 'sel'; + li.onmousedown = ev => { ev.preventDefault(); pick(k); }; + ul.appendChild(li); + }); + ul.hidden = !M.sug.length || document.activeElement !== $('mv-dir'); + const sel = ul.children[M.sel]; + if (sel) sel.scrollIntoView({block: 'nearest'}); + } + function pick(k) { + $('mv-dir').value = M.sug[k]; + suggest(); + } + let sugTimer = null; + $('mv-dir').addEventListener('input', () => { clearTimeout(sugTimer); sugTimer = setTimeout(suggest, 120); }); + $('mv-dir').addEventListener('focus', suggest); + $('mv-dir').addEventListener('blur', () => setTimeout(() => { $('mv-sug').hidden = true; }, 100)); + $('mv-dir').addEventListener('keydown', ev => { + const open = !$('mv-sug').hidden && M.sug.length; + if (ev.key === 'ArrowDown' && open) { ev.preventDefault(); M.sel = (M.sel + 1) % M.sug.length; showSug(); } + else if (ev.key === 'ArrowUp' && open) { ev.preventDefault(); M.sel = (M.sel - 1 + M.sug.length) % M.sug.length; showSug(); } + else if ((ev.key === 'Tab' || ev.key === 'Enter') && open && (M.sel >= 0 || ev.key === 'Tab')) { ev.preventDefault(); pick(Math.max(0, M.sel)); } + else if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } + else if (ev.key === 'Escape' && open) { ev.preventDefault(); ev.stopPropagation(); $('mv-sug').hidden = true; } + }); + $('mv-name').addEventListener('keydown', ev => { if (ev.key === 'Enter') { ev.preventDefault(); $('mv-go').click(); } }); + + function move(e) { + M.src = join(cwd, e.name); + $('mv-src').textContent = M.src + (e.is_dir ? '/' : ''); + setOp('move'); + $('mv-dir').value = cwd === '/' ? '/' : cwd + '/'; + $('mv-name').value = e.name; + $('mv-conflict').value = 'error'; + dirCache.clear(); + openModal($('mvdlg')); + $('mv-dir').focus(); + const end = $('mv-dir').value.length; + $('mv-dir').setSelectionRange(end, end); + } + $('mv-go').onclick = async () => { + const dir = $('mv-dir').value.trim().replace(/\/+$/, '') || '/'; + const name = $('mv-name').value.trim(); + if (!name || name.includes('/')) { toast('имя - без «/»', true); return; } + const dst = (dir === '/' ? '' : dir) + '/' + name; + const conflict = $('mv-conflict').value, move = M.op === 'move'; + const btn = $('mv-go'); + btn.disabled = true; + let r = await api('copy', {src: M.src, dst, move, conflict}); + // A client that predates copying still moves, when nothing is in the way. + if (!r.ok && /неизвестная операция/.test(r.error || '')) { + r = move && conflict === 'error' ? await api('move', {src: M.src, dst}) : {ok: false, error: 'копирование и замена: обновите клиента'}; + } + btn.disabled = false; + if (!r.ok) { toast(r.error || 'ошибка', true); return; } + const skipped = r.data && r.data.skipped; + toast((move ? 'перемещено' : 'скопировано') + (skipped ? ', оставлено как было: ' + skipped : '')); + $('mvdlg').close(); + load(cwd); + }; + $('mvdlg-close').onclick = () => $('mvdlg').close(); async function unzip(e) { const r = await api('unzip', {path: join(cwd, e.name), dest: cwd});