From 65359f5497a70793b52bbde36084cf12da8080b6 Mon Sep 17 00:00:00 2001 From: iRedTea Date: Wed, 14 Oct 2026 19:31:25 +0300 Subject: [PATCH] feat: file and folder properties - mode, owner and group, applied to a folder's contents too (0.0.8) --- VERSION.txt | 2 +- internal/agent/fs.go | 8 ++ internal/agent/fsprops.go | 163 +++++++++++++++++++++++ internal/proto/proto.go | 32 +++++ internal/server/handlers_panel_fs.go | 2 + internal/server/web/templates/files.html | 132 +++++++++++++++++- 6 files changed, 337 insertions(+), 2 deletions(-) create mode 100644 internal/agent/fsprops.go diff --git a/VERSION.txt b/VERSION.txt index 5a5831a..d169b2f 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -0.0.7 +0.0.8 diff --git a/internal/agent/fs.go b/internal/agent/fs.go index 753dd10..414d0cc 100644 --- a/internal/agent/fs.go +++ b/internal/agent/fs.go @@ -72,6 +72,14 @@ func dispatchFS(env proto.TaskEnvelope) (any, error) { var a proto.FSZipArgs _ = json.Unmarshal(env.Args, &a) return fsZip(a.Paths, a.Dest) + case proto.TaskFSStat: + var a proto.FSPathArgs + _ = json.Unmarshal(env.Args, &a) + return fsStat(a.Path) + case proto.TaskFSChmod: + var a proto.FSChmodArgs + _ = json.Unmarshal(env.Args, &a) + return fsChmod(a) default: return nil, fmt.Errorf("неизвестная операция %q", env.Type) } diff --git a/internal/agent/fsprops.go b/internal/agent/fsprops.go new file mode 100644 index 0000000..0f59cd1 --- /dev/null +++ b/internal/agent/fsprops.go @@ -0,0 +1,163 @@ +package agent + +import ( + "bufio" + "errors" + "fmt" + "io/fs" + "os" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + + "github.com/x-grk/longpolling-remote-access/internal/proto" +) + +// fsStat reports a file's properties, following a symlink to what it points +// at, and the user and group names to choose an owner from. +func fsStat(path string) (proto.FSStatResult, error) { + res := proto.FSStatResult{Path: path} + if li, err := os.Lstat(path); err == nil && li.Mode()&os.ModeSymlink != 0 { + res.Link, _ = os.Readlink(path) + } + info, err := os.Stat(path) + if err != nil { + return res, err + } + res.IsDir, res.Size, res.MTime = info.IsDir(), info.Size(), info.ModTime().Unix() + if st, ok := info.Sys().(*syscall.Stat_t); ok { + res.Mode = uint32(st.Mode) & 07777 + res.UID, res.GID = int(st.Uid), int(st.Gid) + } else { + res.Mode = uint32(info.Mode().Perm()) + } + res.Owner, res.Group = strconv.Itoa(res.UID), strconv.Itoa(res.GID) + if u, err := user.LookupId(res.Owner); err == nil { + res.Owner = u.Username + } + if g, err := user.LookupGroupId(res.Group); err == nil { + res.Group = g.Name + } + res.Users, res.Groups = namesIn("/etc/passwd"), namesIn("/etc/group") + return res, nil +} + +// namesIn lists the first field of a passwd- or group-style file. +func namesIn(file string) []string { + f, err := os.Open(file) + if err != nil { + return nil + } + defer f.Close() + var out []string + sc := bufio.NewScanner(f) + for sc.Scan() && len(out) < 1000 { + line := sc.Text() + if line == "" || line[0] == '#' { + continue + } + if name, _, ok := strings.Cut(line, ":"); ok && name != "" { + out = append(out, name) + } + } + sort.Strings(out) + return out +} + +// lookupID resolves a user or group given by name or number; -1 for none. +func lookupID(name string, group bool) (int, error) { + if name == "" { + return -1, nil + } + if n, err := strconv.Atoi(name); err == nil && n >= 0 { + return n, nil + } + if group { + g, err := user.LookupGroup(name) + if err != nil { + return -1, fmt.Errorf("группа %s не найдена", name) + } + return strconv.Atoi(g.Gid) + } + u, err := user.Lookup(name) + if err != nil { + return -1, fmt.Errorf("пользователь %s не найден", name) + } + return strconv.Atoi(u.Uid) +} + +// fsChmod sets mode, owner and group on a path, and with Recursive on all +// that is inside a folder. Symlinks inside are not followed. +func fsChmod(a proto.FSChmodArgs) (any, error) { + if a.Path == "" { + return nil, errors.New("не указан путь") + } + if a.Recursive && filepath.Clean(a.Path) == "/" { + return nil, errors.New("отказ: рекурсивно для корня нельзя") + } + if a.Mode != nil && *a.Mode > 07777 { + return nil, errors.New("неверные права") + } + uid, err := lookupID(a.Owner, false) + if err != nil { + return nil, err + } + gid, err := lookupID(a.Group, true) + if err != nil { + return nil, err + } + changed, failed := 0, 0 + var errs []string + // The path itself always gets the change; Only picks among what is in it. + apply := func(p string, isDir, isLink, self bool) { + if !self && ((a.Only == "files" && isDir) || (a.Only == "dirs" && !isDir)) { + return + } + var err error + if uid >= 0 || gid >= 0 { + err = os.Lchown(p, uid, gid) + } + // A symlink has no mode of its own: chmod would change its target. + if err == nil && a.Mode != nil && !isLink { + err = syscall.Chmod(p, *a.Mode) + } + if err != nil { + failed++ + if len(errs) < 5 { + errs = append(errs, err.Error()) + } + return + } + changed++ + } + info, err := os.Stat(a.Path) + if err != nil { + return nil, err + } + if !a.Recursive || !info.IsDir() { + apply(a.Path, info.IsDir(), false, true) + } else { + err = filepath.WalkDir(a.Path, func(p string, d fs.DirEntry, err error) error { + if err != nil { + failed++ + if len(errs) < 5 { + errs = append(errs, err.Error()) + } + return nil + } + apply(p, d.IsDir(), d.Type()&fs.ModeSymlink != 0, p == a.Path) + return nil + }) + if err != nil { + return nil, err + } + } + res := map[string]any{"changed": changed, "failed": failed, "errors": errs} + if failed > 0 && changed == 0 { + return res, errors.New(errs[0]) + } + return res, nil +} diff --git a/internal/proto/proto.go b/internal/proto/proto.go index b98ad1e..af2d7e0 100644 --- a/internal/proto/proto.go +++ b/internal/proto/proto.go @@ -85,6 +85,8 @@ const ( TaskFSZip = "fs.zip" // zip selected paths (native archive/zip) TaskFSMove = "fs.move" // move/rename 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 TaskFSPut = "fs.put" // write a file the browser sends in pieces TaskFSSend = "fs.send" // send a file to the browser in pieces @@ -146,6 +148,36 @@ type FSMoveArgs struct { Dst string `json:"dst"` } +// FSStatResult is returned for TaskFSStat. Mode holds the permission bits +// with setuid, setgid and sticky (07777). Users and Groups are the names on +// the machine, offered when choosing an owner. +type FSStatResult struct { + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Link string `json:"link,omitempty"` // where a symlink points + Size int64 `json:"size"` + MTime int64 `json:"mtime"` + Mode uint32 `json:"mode"` + UID int `json:"uid"` + GID int `json:"gid"` + Owner string `json:"owner"` + Group string `json:"group"` + Users []string `json:"users"` + Groups []string `json:"groups"` +} + +// FSChmodArgs are the arguments of TaskFSChmod. Mode nil, Owner or Group +// empty leave that as it is. Recursive applies to everything inside a folder +// as well, Only narrowing it to "files" or "dirs". +type FSChmodArgs struct { + Path string `json:"path"` + Mode *uint32 `json:"mode,omitempty"` + Owner string `json:"owner,omitempty"` + Group string `json:"group,omitempty"` + Recursive bool `json:"recursive,omitempty"` + Only string `json:"only,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 0f39f69..3f4b1ef 100644 --- a/internal/server/handlers_panel_fs.go +++ b/internal/server/handlers_panel_fs.go @@ -19,6 +19,8 @@ var fsOps = map[string]string{ "unzip": proto.TaskFSUnzip, "zip": proto.TaskFSZip, "move": proto.TaskFSMove, + "stat": proto.TaskFSStat, + "chmod": proto.TaskFSChmod, } // 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 17faa45..c0e1531 100644 --- a/internal/server/web/templates/files.html +++ b/internal/server/web/templates/files.html @@ -71,6 +71,28 @@ #save.saving::before { content:''; width:12px; height:12px; border:2px solid rgba(255,255,255,.35); border-top-color:#fff; border-radius:50%; animation:spin .7s linear infinite; } #save[hidden] { display:none; } + /* 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; } + /* Properties: owner, group and mode, like FileZilla's. */ + #propdlg { max-width:520px; } + #propdlg .dlg-b { padding:14px 18px 16px; } + .props .pp-path { font-family:ui-monospace,Menlo,monospace; font-size:13px; overflow-wrap:anywhere; } + .props .pp-info { font-size:13px; margin:2px 0 12px; } + .props .pp-own { display:grid; grid-template-columns:1fr 1fr; gap:12px; } + .props .pp-own label { margin:0; } + .props .pp-own input { margin-top:4px; height:36px; padding:6px 10px; } + .props table.pp-perm { margin:12px 0 4px; } + .props .pp-perm th, .props .pp-perm td { padding:4px 8px; text-align:center; font-size:13px; } + .props .pp-perm tbody th { text-align:left; color:var(--text); font-weight:400; } + .props .pp-perm input { width:16px; height:16px; cursor:pointer; } + .props .pp-num { display:flex; align-items:center; gap:10px; margin:8px 0 0; } + .props .pp-num input { width:80px; height:34px; padding:4px 10px; font-family:ui-monospace,Menlo,monospace; } + .props .pp-num input.bad { border-color:var(--danger); } + .props .pp-rec { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:8px 16px; margin-top:12px; padding-top:12px; border-top:1px solid var(--border); } + .props .pp-rec label.check { display:inline-flex; align-items:center; gap:8px; margin:0; color:var(--text); font-size:14px; cursor:pointer; } + .props .pp-rec select { min-width:0; padding:6px 32px 6px 12px; } + .props .pp-act { display:flex; justify-content:flex-end; margin-top:14px; } /* File viewer: the code editor, a picture / video / sound, or the hex editor. */ #viewer { max-width:1100px; } #viewer .dlg-h { flex-wrap:wrap; } @@ -195,6 +217,7 @@ +
@@ -226,6 +249,7 @@ +
@@ -266,6 +290,40 @@
+ +
+ Свойства + +
+
+
+
+
+ + +
+ + + + + + + + +
ЧтениеЗаписьВыполнение
Владелец
Группа
Остальные
+ + +
+
+
+
@@ -299,7 +357,7 @@ // goes inside it then, so "сохранено" or an error shows over the editor. let toastTimer = null; const toast = (msg, err) => { - const t = $('toast'), host = document.querySelector('dialog[open]') || document.body; + const t = $('toast'), host = [...document.querySelectorAll('dialog[open]')].pop() || document.body; if (t.parentNode !== host) host.appendChild(t); t.textContent = msg; t.className = 'toast show' + (err ? ' err' : ''); @@ -921,6 +979,78 @@ $('viewer').addEventListener('cancel', ev => { ev.preventDefault(); if (F.open) closeFind(); else closeViewer(); }); $('closev').onclick = closeViewer; + // ---------- Properties ---------- + // Owner, group and mode of a file or folder; a folder's can go to all that + // is in it too. + const P = { path: '', orig: null }; + const modeStr = m => m.toString(8).padStart(m > 0o777 ? 4 : 3, '0'); + const permBoxes = () => [...document.querySelectorAll('#propdlg .pp-perm input[data-bit]')]; + function showMode(m) { + permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit)); + $('pp-mode').value = modeStr(m); + $('pp-mode').classList.remove('bad'); + } + function readMode() { + const v = $('pp-mode').value.trim(); + return /^[0-7]{3,4}$/.test(v) ? parseInt(v, 8) : null; + } + permBoxes().forEach(b => b.onchange = () => { + let m = (readMode() ?? P.orig.mode) & 0o7000; + permBoxes().forEach(x => { if (x.checked) m |= +x.dataset.bit; }); + showMode(m); + }); + $('pp-mode').oninput = () => { + const m = readMode(); + $('pp-mode').classList.toggle('bad', m === null); + if (m !== null) permBoxes().forEach(b => b.checked = !!(m & +b.dataset.bit)); + }; + $('pp-recursive').onchange = () => $('pp-only').disabled = !$('pp-recursive').checked; + + async function openProps(path) { + const r = await api('stat', {path}); + if (!r.ok) { + toast(/неизвестная операция/.test(r.error || '') ? 'свойства недоступны: обновите клиента' : (r.error || 'ошибка'), true); + return; + } + const d = r.data; + P.path = path; P.orig = d; + $('pp-path').textContent = path; + $('pp-info').textContent = (d.is_dir ? 'папка' : 'файл · ' + fmtSize(d.size)) + ' · изменён ' + fmtTime(d.mtime) + + (d.link ? ' · ссылка на ' + d.link : ''); + $('pp-owner').value = d.owner; $('pp-group').value = d.group; + $('pp-users').innerHTML = (d.users || []).map(u => '