feat: file and folder properties - mode, owner and group, applied to a folder's contents too (0.0.8)
This commit is contained in:
1 parent
8eb2bbd35d
commit
65359f5497
6 files changed
+337
-2
No files matched your search
+1
-1
@@ -1 +1 @@
|
||||
0.0.7
|
||||
0.0.8
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 @@
|
||||
<button class="btn ghost small" id="up">↑ Вверх</button>
|
||||
<input class="fm-path" id="path" value="/" spellcheck="false" autocapitalize="off" autocomplete="off">
|
||||
<button type="button" class="busy" id="busy" title=""><span class="spinner" id="spin"></span><span id="uppct"></span></button>
|
||||
<button class="btn ghost small icon sq" id="dirprops" title="Свойства папки" aria-label="Свойства папки"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
|
||||
<button class="btn ghost small" id="refresh">Обновить</button>
|
||||
</div>
|
||||
<div class="fm-bar">
|
||||
@@ -226,6 +249,7 @@
|
||||
<button class="btn ghost small" id="vtext" hidden>Редактировать</button>
|
||||
<button class="btn ghost small" id="vhex" hidden>Редактировать бинарно</button>
|
||||
<button class="btn small" id="save" hidden>Сохранить</button>
|
||||
<button class="btn ghost small icon sq" id="vprops" title="Свойства" aria-label="Свойства"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M21 4h-7M10 4H3M21 12h-9M8 12H3M21 20h-5M12 20H3M14 2v4M8 10v4M16 18v4"/></svg></button>
|
||||
<button class="btn ghost small icon close x" id="closev" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
<div class="vmsg muted" id="vmsg"></div>
|
||||
</div>
|
||||
@@ -266,6 +290,40 @@
|
||||
<div class="dlg-b" id="updlg-list"></div>
|
||||
</dialog>
|
||||
|
||||
<dialog id="propdlg">
|
||||
<div class="dlg-h">
|
||||
<strong style="flex:1">Свойства</strong>
|
||||
<button class="btn ghost small icon close x" id="propdlg-close" title="Закрыть" aria-label="Закрыть"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
|
||||
</div>
|
||||
<div class="dlg-b props">
|
||||
<div class="pp-path" id="pp-path"></div>
|
||||
<div class="pp-info muted" id="pp-info"></div>
|
||||
<div class="pp-own">
|
||||
<label>Владелец<input type="text" id="pp-owner" list="pp-users" autocomplete="off" spellcheck="false"></label>
|
||||
<label>Группа<input type="text" id="pp-group" list="pp-groups" autocomplete="off" spellcheck="false"></label>
|
||||
</div>
|
||||
<datalist id="pp-users"></datalist><datalist id="pp-groups"></datalist>
|
||||
<table class="pp-perm">
|
||||
<thead><tr><th></th><th>Чтение</th><th>Запись</th><th>Выполнение</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><th>Владелец</th><td><input type="checkbox" data-bit="256"></td><td><input type="checkbox" data-bit="128"></td><td><input type="checkbox" data-bit="64"></td></tr>
|
||||
<tr><th>Группа</th><td><input type="checkbox" data-bit="32"></td><td><input type="checkbox" data-bit="16"></td><td><input type="checkbox" data-bit="8"></td></tr>
|
||||
<tr><th>Остальные</th><td><input type="checkbox" data-bit="4"></td><td><input type="checkbox" data-bit="2"></td><td><input type="checkbox" data-bit="1"></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<label class="pp-num">Числовое значение <input type="text" id="pp-mode" maxlength="4" inputmode="numeric" autocomplete="off"></label>
|
||||
<div class="pp-rec" id="pp-rec" hidden>
|
||||
<label class="check"><input type="checkbox" id="pp-recursive"> Применить ко всем вложенным</label>
|
||||
<select id="pp-only" disabled>
|
||||
<option value="">к файлам и папкам</option>
|
||||
<option value="files">только к файлам</option>
|
||||
<option value="dirs">только к папкам</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="pp-act"><button type="button" id="pp-apply">Применить</button></div>
|
||||
</div>
|
||||
</dialog>
|
||||
|
||||
<div class="toast" id="toast"></div>
|
||||
|
||||
<script src="/static/codemirror/codemirror.js"></script>
|
||||
@@ -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 => '<option value="' + esc(u) + '">').join('');
|
||||
$('pp-groups').innerHTML = (d.groups || []).map(g => '<option value="' + esc(g) + '">').join('');
|
||||
showMode(d.mode);
|
||||
$('pp-rec').hidden = !d.is_dir;
|
||||
$('pp-recursive').checked = false; $('pp-only').value = ''; $('pp-only').disabled = true;
|
||||
if (!$('propdlg').open) $('propdlg').showModal();
|
||||
}
|
||||
$('pp-apply').onclick = async () => {
|
||||
const o = P.orig, mode = readMode();
|
||||
if (mode === null) { toast('права - три или четыре восьмеричные цифры, например 755', true); return; }
|
||||
const rec = o.is_dir && $('pp-recursive').checked;
|
||||
const owner = $('pp-owner').value.trim(), group = $('pp-group').value.trim();
|
||||
const args = {path: P.path};
|
||||
if (mode !== o.mode || rec) args.mode = mode;
|
||||
if (owner && (owner !== o.owner || rec)) args.owner = owner;
|
||||
if (group && (group !== o.group || rec)) args.group = group;
|
||||
if (rec) { args.recursive = true; args.only = $('pp-only').value; }
|
||||
if (Object.keys(args).length === 1) { toast('ничего не изменено'); return; }
|
||||
if (rec && !confirm('Применить к «' + P.path + '» и всему, что внутри?')) return;
|
||||
const btn = $('pp-apply');
|
||||
btn.disabled = true;
|
||||
const r = await api('chmod', args);
|
||||
btn.disabled = false;
|
||||
if (!r.ok) { toast(/неизвестная операция/.test(r.error || '') ? 'обновите клиента' : (r.error || 'ошибка'), true); return; }
|
||||
const c = r.data || {};
|
||||
toast(rec ? 'применено: ' + c.changed + (c.failed ? ', не удалось: ' + c.failed + ' (' + (c.errors || [])[0] + ')' : '') : 'применено', !!c.failed);
|
||||
openProps(P.path);
|
||||
load(cwd);
|
||||
};
|
||||
$('propdlg-close').onclick = () => $('propdlg').close();
|
||||
$('vprops').onclick = () => { if (V.path) openProps(V.path); };
|
||||
$('dirprops').onclick = () => openProps(cwd);
|
||||
|
||||
async function del(e) {
|
||||
if (e.is_dir) {
|
||||
if (!confirm('Рекурсивно удалить каталог "'+e.name+'" со всем содержимым?')) return;
|
||||
|
||||
Reference in new issue
Block a user