From 63b020d5565d27aad3601eac9d9cfd1dfcc8a324 Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Fri, 18 Sep 2026 05:39:57 -0700 Subject: [PATCH] Files tab: browser with breadcrumbs/sort/filter, text editor, image preview, streamed upload with progress, download, mkdir/touch/rename/move/chmod/delete, open-in-terminal; root toggle via sudo-reexecuted 'hamlab fs' helper Co-Authored-By: Claude Fable 5.1 --- PLAN.md | 13 +- cmd/hamlab/main.go | 13 +- internal/api/server.go | 254 +++++++++++++++++++- internal/files/files.go | 496 +++++++++++++++++++++++++++++++++++++++ web/src/Files.tsx | 233 ++++++++++++++++++ web/src/Shell.tsx | 18 +- web/src/TerminalPane.tsx | 5 +- web/src/api.ts | 33 +++ web/src/styles.css | 38 +++ 9 files changed, 1094 insertions(+), 9 deletions(-) create mode 100644 internal/files/files.go create mode 100644 web/src/Files.tsx diff --git a/PLAN.md b/PLAN.md index bf7e75a..2494dad 100644 --- a/PLAN.md +++ b/PLAN.md @@ -182,7 +182,18 @@ something long in one, switch away and back, refresh — it's all still there. MVP URL space so `host` can be inserted later without breaking clients. - **Auth hardening** — WebAuthn passkeys (phone Face ID), TOTP fallback, session list + revoke, optional NPM access list restricting to WireGuard/LAN. -- **Files** — browse/upload/download/edit under `/`, with a sudo toggle. +- **Files tab** — **shipped 2026-09-18** (`internal/files`, `Files.tsx`): browse from + `/` with breadcrumbs, an editable path box, sort, hidden-file toggle and a filter; + rows show type icon, size, modified, perms and owner, link targets and broken links. + Text files (≤ 2 MiB, sniffed) open in an editor with Ctrl+S / Save; images preview; + everything else downloads. Upload via drag-and-drop or picker with per-file progress + (streamed, no size limit). New folder / file, rename, move, chmod, copy path, delete + (folders need the name typed back), and "open in terminal", which `cd`s the active + shell (or a fresh one) to the folder. **Sudo toggle**: root access re-executes the + same binary as `hamlab fs ` under `sudo -n`, streaming bodies over stdin/stdout, + so both modes share one implementation and one set of rules (absolute paths only; + `/`, `/etc`, `/usr`, … are never deleted or moved whole). Every write is logged with + the caller's IP. - **Docker read-only view** — `docker ps`/logs; dockhand stays the source of truth. - **Notifications** — push on service failure / SMART warning / UPS event. - **Terminal extras** — search, copy-mode, font/theme, split panes, upload via diff --git a/cmd/hamlab/main.go b/cmd/hamlab/main.go index cafca60..9f4d289 100644 --- a/cmd/hamlab/main.go +++ b/cmd/hamlab/main.go @@ -22,6 +22,7 @@ import ( "hamlab/internal/auth" "hamlab/internal/config" "hamlab/internal/cron" + "hamlab/internal/files" "hamlab/internal/metrics" "hamlab/internal/services" "hamlab/internal/storage" @@ -42,7 +43,7 @@ func main() { } cfgPath := flag.String("config", defaultCfg, "path to config.json") flag.Usage = func() { - fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version]\n") + fmt.Fprintf(os.Stderr, "usage: hamlab [-config path] [serve|passwd|version|fs …]\n") flag.PrintDefaults() } flag.Parse() @@ -51,6 +52,9 @@ func main() { if flag.NArg() > 0 { cmd = flag.Arg(0) } + if cmd == "fs" { // privileged helper for the Files tab (run via sudo by the daemon) + os.Exit(files.Helper(flag.Args()[1:], os.Stdin, os.Stdout, os.Stderr)) + } cfg, err := config.Load(*cfgPath) if err != nil { log.Fatal(err) @@ -151,7 +155,12 @@ func serve(cfg *config.Config) error { tm := tasks.New() up := updates.New(svc.Sudo(), tm) sto := storage.New(svc.Sudo()) - srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, web.FS(), version) + self, _ := os.Executable() + var sudoFS files.FS + if svc.Sudo() && self != "" { + sudoFS = files.Sudo{Self: self} + } + srv := api.New(cfg, sessions, mgr, col, st, svc, cr, tm, up, sto, files.Local{}, sudoFS, web.FS(), version) hs := &http.Server{ Addr: cfg.Listen, Handler: srv.Handler(), diff --git a/internal/api/server.go b/internal/api/server.go index b3870c2..e0fd397 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,9 +8,12 @@ import ( "io" "io/fs" "log" + "mime" "net/http" + "net/url" "os" "os/user" + "path/filepath" "strconv" "strings" "time" @@ -20,6 +23,7 @@ import ( "hamlab/internal/auth" "hamlab/internal/config" "hamlab/internal/cron" + "hamlab/internal/files" "hamlab/internal/metrics" "hamlab/internal/services" "hamlab/internal/storage" @@ -43,6 +47,8 @@ type Server struct { tasks *tasks.Manager updates *updates.Manager storage *storage.Manager + fsLocal files.FS + fsSudo files.FS // nil without passwordless sudo version string started time.Time user string @@ -51,9 +57,9 @@ type Server struct { mux *http.ServeMux } -func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, ui fs.FS, version string) *Server { +func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *metrics.Collector, st *store.Store, svc *services.Manager, cr *cron.Manager, tm *tasks.Manager, up *updates.Manager, sto *storage.Manager, fsLocal, fsSudo files.FS, ui fs.FS, version string) *Server { host, _ := os.Hostname() - s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, ui: ui, version: version, + s := &Server{cfg: cfg, sessions: sessions, mgr: mgr, metrics: col, store: st, svc: svc, cron: cr, tasks: tm, updates: up, storage: sto, fsLocal: fsLocal, fsSudo: fsSudo, ui: ui, version: version, started: time.Now(), host: host, user: os.Getenv("USER")} if u, err := user.Current(); err == nil && u.Username != "" { s.user = u.Username @@ -102,6 +108,17 @@ func New(cfg *config.Config, sessions *auth.Sessions, mgr *term.Manager, col *me m.HandleFunc("GET /api/updates/changelog", s.auth(s.updatesChangelog)) m.HandleFunc("POST /api/power/{action}", s.auth(s.power)) m.HandleFunc("POST /api/updates/install", s.auth(s.updatesInstall)) + m.HandleFunc("GET /api/files", s.auth(s.filesList)) + m.HandleFunc("GET /api/files/stat", s.auth(s.filesStat)) + m.HandleFunc("GET /api/files/read", s.auth(s.filesRead)) + m.HandleFunc("GET /api/files/download", s.auth(s.filesRead)) + m.HandleFunc("PUT /api/files/write", s.auth(s.filesWrite)) + m.HandleFunc("PUT /api/files/upload", s.auth(s.filesUpload)) + m.HandleFunc("POST /api/files/mkdir", s.auth(s.filesMkdir)) + m.HandleFunc("POST /api/files/touch", s.auth(s.filesTouch)) + m.HandleFunc("POST /api/files/rename", s.auth(s.filesRename)) + m.HandleFunc("POST /api/files/delete", s.auth(s.filesDelete)) + m.HandleFunc("POST /api/files/chmod", s.auth(s.filesChmod)) m.HandleFunc("GET /api/storage", s.auth(s.storageOverview)) m.HandleFunc("GET /api/storage/smart/{dev}", s.auth(s.storageSmart)) m.HandleFunc("POST /api/storage/smart/{dev}/test/{kind}", s.auth(s.storageSmartTest)) @@ -597,6 +614,239 @@ func (s *Server) power(w http.ResponseWriter, r *http.Request) { w.WriteHeader(204) } +// ---- files ---- + +// fsFor picks the backend: ?sudo=1 asks for root, which needs passwordless sudo. +func (s *Server) fsFor(w http.ResponseWriter, r *http.Request) (files.FS, bool) { + if r.URL.Query().Get("sudo") == "1" { + if s.fsSudo == nil { + jsonErr(w, 403, "root access needs passwordless sudo on the host") + return nil, false + } + return s.fsSudo, true + } + return s.fsLocal, true +} + +func fsErr(w http.ResponseWriter, err error) { + code := 400 + switch { + case errors.Is(err, os.ErrNotExist): + code = 404 + case errors.Is(err, os.ErrPermission), strings.Contains(err.Error(), "permission denied"): + code = 403 + } + msg := err.Error() + if pe, ok := err.(*os.PathError); ok { // "open /x: permission denied" -> "permission denied" + msg = pe.Err.Error() + } + jsonErr(w, code, msg) +} + +func cleanQuery(w http.ResponseWriter, r *http.Request, key string) (string, bool) { + p, err := files.Clean(r.URL.Query().Get(key)) + if err != nil { + jsonErr(w, 400, err.Error()) + return "", false + } + return p, true +} + +func (s *Server) filesList(w http.ResponseWriter, r *http.Request) { + fsys, ok := s.fsFor(w, r) + if !ok { + return + } + p, ok := cleanQuery(w, r, "path") + if !ok { + return + } + l, err := fsys.List(r.Context(), p) + if err != nil { + fsErr(w, err) + return + } + jsonOK(w, 200, l) +} + +func (s *Server) filesStat(w http.ResponseWriter, r *http.Request) { + fsys, ok := s.fsFor(w, r) + if !ok { + return + } + p, ok := cleanQuery(w, r, "path") + if !ok { + return + } + in, err := fsys.Stat(r.Context(), p) + if err != nil { + fsErr(w, err) + return + } + jsonOK(w, 200, in) +} + +// filesRead streams a file. /download adds an attachment header; ?max= caps the bytes. +func (s *Server) filesRead(w http.ResponseWriter, r *http.Request) { + fsys, ok := s.fsFor(w, r) + if !ok { + return + } + p, ok := cleanQuery(w, r, "path") + if !ok { + return + } + in, err := fsys.Stat(r.Context(), p) + if err != nil { + fsErr(w, err) + return + } + if in.Type == "dir" { + jsonErr(w, 400, "is a directory") + return + } + if mx, _ := strconv.ParseInt(r.URL.Query().Get("max"), 10, 64); mx > 0 && in.Size > mx { + jsonErr(w, 413, "file too large") + return + } + f, err := fsys.Open(r.Context(), p) + if err != nil { + fsErr(w, err) + return + } + defer f.Close() + name := filepath.Base(p) + ct := mime.TypeByExtension(filepath.Ext(name)) + if ct == "" { + if in.Text { + ct = "text/plain; charset=utf-8" + } else { + ct = "application/octet-stream" + } + } + w.Header().Set("Content-Type", ct) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("X-Content-Type-Options", "nosniff") + if strings.HasSuffix(r.URL.Path, "/download") { + w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(name)) + } else { + w.Header().Set("Content-Disposition", "inline; filename*=UTF-8''"+url.PathEscape(name)) + } + if in.Size > 0 { + w.Header().Set("Content-Length", strconv.FormatInt(in.Size, 10)) + } + _, _ = io.Copy(w, f) +} + +func (s *Server) filesWrite(w http.ResponseWriter, r *http.Request) { + fsys, ok := s.fsFor(w, r) + if !ok { + return + } + p, ok := cleanQuery(w, r, "path") + if !ok { + return + } + if err := fsys.Write(r.Context(), p, io.LimitReader(r.Body, 8<<20)); err != nil { + fsErr(w, err) + return + } + log.Printf("files: wrote %s (from %s)", p, clientIP(r)) + w.WriteHeader(204) +} + +// filesUpload streams the body into /. +func (s *Server) filesUpload(w http.ResponseWriter, r *http.Request) { + fsys, ok := s.fsFor(w, r) + if !ok { + return + } + dir, ok := cleanQuery(w, r, "path") + if !ok { + return + } + name := r.URL.Query().Get("name") + if name == "" || name != filepath.Base(name) || name == "." || name == ".." { + jsonErr(w, 400, "bad file name") + return + } + dst := filepath.Join(dir, name) + if err := fsys.Write(r.Context(), dst, r.Body); err != nil { + fsErr(w, err) + return + } + log.Printf("files: uploaded %s (from %s)", dst, clientIP(r)) + w.WriteHeader(201) +} + +func (s *Server) filesOp(w http.ResponseWriter, r *http.Request, fn func(fsys files.FS, body map[string]any, p string) error) { + fsys, ok := s.fsFor(w, r) + if !ok { + return + } + var body map[string]any + if err := json.NewDecoder(io.LimitReader(r.Body, 64<<10)).Decode(&body); err != nil { + jsonErr(w, 400, "bad request") + return + } + p, err := files.Clean(str(body["path"])) + if err != nil { + jsonErr(w, 400, err.Error()) + return + } + if err := fn(fsys, body, p); err != nil { + fsErr(w, err) + return + } + w.WriteHeader(204) +} + +func str(v any) string { s, _ := v.(string); return s } + +func (s *Server) filesMkdir(w http.ResponseWriter, r *http.Request) { + s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error { + log.Printf("files: mkdir %s (from %s)", p, clientIP(r)) + return fsys.Mkdir(r.Context(), p) + }) +} + +func (s *Server) filesTouch(w http.ResponseWriter, r *http.Request) { + s.filesOp(w, r, func(fsys files.FS, _ map[string]any, p string) error { + log.Printf("files: create %s (from %s)", p, clientIP(r)) + return fsys.Touch(r.Context(), p) + }) +} + +func (s *Server) filesRename(w http.ResponseWriter, r *http.Request) { + s.filesOp(w, r, func(fsys files.FS, body map[string]any, from string) error { + to, err := files.Clean(str(body["to"])) + if err != nil { + return err + } + log.Printf("files: rename %s -> %s (from %s)", from, to, clientIP(r)) + return fsys.Rename(r.Context(), from, to) + }) +} + +func (s *Server) filesDelete(w http.ResponseWriter, r *http.Request) { + s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error { + rec, _ := body["recursive"].(bool) + log.Printf("files: delete %s recursive=%v (from %s)", p, rec, clientIP(r)) + return fsys.Delete(r.Context(), p, rec) + }) +} + +func (s *Server) filesChmod(w http.ResponseWriter, r *http.Request) { + s.filesOp(w, r, func(fsys files.FS, body map[string]any, p string) error { + perm, err := files.ParsePerm(str(body["mode"])) + if err != nil { + return err + } + log.Printf("files: chmod %04o %s (from %s)", perm, p, clientIP(r)) + return fsys.Chmod(r.Context(), p, perm) + }) +} + // ---- storage (Disks tab) ---- func (s *Server) updatesInstall(w http.ResponseWriter, r *http.Request) { diff --git a/internal/files/files.go b/internal/files/files.go new file mode 100644 index 0000000..c84fc61 --- /dev/null +++ b/internal/files/files.go @@ -0,0 +1,496 @@ +// Package files is the Files tab: browse, read, write, upload, download, mkdir, +// rename, chmod and delete anywhere on the host. +// +// Two backends share one interface. Local runs in-process as the daemon's user. Sudo +// re-executes this same binary as root (`sudo -n hamlab fs …`) for one operation at a +// time, streaming file bodies over its stdin/stdout, so root access uses exactly the +// same code and the same validation as the unprivileged path. +package files + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "os/exec" + "os/user" + "path/filepath" + "sort" + "strconv" + "strings" + "syscall" + "time" +) + +type Entry struct { + Name string `json:"name"` + Type string `json:"type"` // dir | file | link | other + Size int64 `json:"size"` + Mode string `json:"mode"` // "-rw-r--r--" + Perm string `json:"perm"` // "0644" + Mtime int64 `json:"mtime"` + User string `json:"user"` + Group string `json:"group"` + Target string `json:"target,omitempty"` // link target + Broken bool `json:"broken,omitempty"` // dangling link +} + +type Listing struct { + Path string `json:"path"` + Parent string `json:"parent,omitempty"` + Entries []Entry `json:"entries"` +} + +type Info struct { + Entry + Path string `json:"path"` + Text bool `json:"text"` // looks like text (for the editor) +} + +// FS is what the API talks to. +type FS interface { + List(ctx context.Context, path string) (Listing, error) + Stat(ctx context.Context, path string) (Info, error) + Open(ctx context.Context, path string) (io.ReadCloser, error) + Write(ctx context.Context, path string, r io.Reader) error + Mkdir(ctx context.Context, path string) error + Touch(ctx context.Context, path string) error + Rename(ctx context.Context, from, to string) error + Delete(ctx context.Context, path string, recursive bool) error + Chmod(ctx context.Context, path string, perm os.FileMode) error +} + +// ---- path rules ---- + +// Clean rejects relative paths and normalises the rest. Everything else on the host is +// reachable on purpose: this is a root-capable console, the gate is the login. +func Clean(p string) (string, error) { + if p == "" { + return "", errors.New("path required") + } + if !strings.HasPrefix(p, "/") { + return "", errors.New("path must be absolute") + } + c := filepath.Clean(p) + if strings.ContainsRune(c, 0) { + return "", errors.New("bad path") + } + return c, nil +} + +// protected are never deleted or renamed as a whole, no matter who asks. +var protected = map[string]bool{"/": true, "/bin": true, "/boot": true, "/dev": true, "/etc": true, "/home": true, "/lib": true, "/lib64": true, + "/opt": true, "/proc": true, "/root": true, "/run": true, "/sbin": true, "/srv": true, "/sys": true, "/tmp": true, "/usr": true, "/var": true} + +func ParsePerm(s string) (os.FileMode, error) { + v, err := strconv.ParseUint(strings.TrimPrefix(s, "0"), 8, 32) + if err != nil || v > 0o7777 { + return 0, errors.New("mode must be octal like 0644") + } + return os.FileMode(v), nil +} + +// ---- Local ---- + +type Local struct{} + +var uidCache, gidCache = map[uint32]string{}, map[uint32]string{} + +func owner(st os.FileInfo) (string, string) { + sys, ok := st.Sys().(*syscall.Stat_t) + if !ok { + return "", "" + } + u, ok := uidCache[sys.Uid] + if !ok { + u = strconv.Itoa(int(sys.Uid)) + if x, err := user.LookupId(u); err == nil { + u = x.Username + } + uidCache[sys.Uid] = u + } + g, ok := gidCache[sys.Gid] + if !ok { + g = strconv.Itoa(int(sys.Gid)) + if x, err := user.LookupGroupId(g); err == nil { + g = x.Name + } + gidCache[sys.Gid] = g + } + return u, g +} + +func entryOf(dir, name string, lst os.FileInfo) Entry { + e := Entry{Name: name, Size: lst.Size(), Mode: lst.Mode().String(), Perm: fmt.Sprintf("%04o", lst.Mode().Perm()), Mtime: lst.ModTime().UnixMilli()} + e.User, e.Group = owner(lst) + switch { + case lst.Mode()&os.ModeSymlink != 0: + e.Type = "link" + full := filepath.Join(dir, name) + e.Target, _ = os.Readlink(full) + if st, err := os.Stat(full); err != nil { + e.Broken = true + } else if st.IsDir() { + e.Type = "dir" // keep dir semantics for navigation; Target says it's a link + } else { + e.Size = st.Size() + } + case lst.IsDir(): + e.Type = "dir" + case lst.Mode().IsRegular(): + e.Type = "file" + default: + e.Type = "other" + } + return e +} + +func (Local) List(_ context.Context, path string) (Listing, error) { + ents, err := os.ReadDir(path) + if err != nil { + return Listing{}, err + } + l := Listing{Path: path, Entries: make([]Entry, 0, len(ents))} + if path != "/" { + l.Parent = filepath.Dir(path) + } + for _, d := range ents { + lst, err := d.Info() // Lstat + if err != nil { + continue + } + l.Entries = append(l.Entries, entryOf(path, d.Name(), lst)) + } + sort.Slice(l.Entries, func(i, j int) bool { + a, b := l.Entries[i], l.Entries[j] + if (a.Type == "dir") != (b.Type == "dir") { + return a.Type == "dir" + } + return strings.ToLower(a.Name) < strings.ToLower(b.Name) + }) + return l, nil +} + +func (Local) Stat(_ context.Context, path string) (Info, error) { + lst, err := os.Lstat(path) + if err != nil { + return Info{}, err + } + in := Info{Entry: entryOf(filepath.Dir(path), filepath.Base(path), lst), Path: path} + if in.Type == "file" && in.Size <= 8<<20 { + if f, err := os.Open(path); err == nil { + buf := make([]byte, 4096) + n, _ := f.Read(buf) + f.Close() + in.Text = looksText(buf[:n]) + } + } + return in, nil +} + +func looksText(b []byte) bool { + if len(b) == 0 { + return true + } + ctrl := 0 + for _, c := range b { + if c == 0 { + return false + } + if c < 32 && c != '\n' && c != '\r' && c != '\t' && c != '\f' && c != 0x1b { + ctrl++ + } + } + return ctrl*20 < len(b) +} + +func (Local) Open(_ context.Context, path string) (io.ReadCloser, error) { + st, err := os.Stat(path) + if err != nil { + return nil, err + } + if st.IsDir() { + return nil, errors.New("is a directory") + } + return os.Open(path) +} + +// Write replaces the file's contents, keeping its mode; new files get 0644 (umask). +func (Local) Write(_ context.Context, path string, r io.Reader) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + if _, err := io.Copy(f, r); err != nil { + f.Close() + return err + } + return f.Close() +} + +func (Local) Mkdir(_ context.Context, path string) error { return os.Mkdir(path, 0o755) } + +func (Local) Touch(_ context.Context, path string) error { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + return err + } + return f.Close() +} + +func (Local) Rename(_ context.Context, from, to string) error { + if protected[from] { + return errors.New("refusing to move " + from) + } + if _, err := os.Lstat(to); err == nil { + return errors.New("destination exists") + } + return os.Rename(from, to) +} + +func (Local) Delete(_ context.Context, path string, recursive bool) error { + if protected[path] { + return errors.New("refusing to delete " + path) + } + lst, err := os.Lstat(path) + if err != nil { + return err + } + if lst.IsDir() && recursive { + return os.RemoveAll(path) + } + return os.Remove(path) // fails on a non-empty dir, by design +} + +func (Local) Chmod(_ context.Context, path string, perm os.FileMode) error { + return os.Chmod(path, perm) +} + +// ---- Sudo: the same operations, run as root in a child hamlab ---- + +type Sudo struct{ Self string } + +func (s Sudo) cmd(ctx context.Context, args ...string) *exec.Cmd { + full := append([]string{"sudo", "-n", s.Self, "fs"}, args...) + return exec.CommandContext(ctx, full[0], full[1:]...) +} + +func (s Sudo) runJSON(ctx context.Context, out any, args ...string) error { + c := s.cmd(ctx, args...) + var errb strings.Builder + c.Stderr = &errb + b, err := c.Output() + if err != nil { + if msg := strings.TrimSpace(errb.String()); msg != "" { + return errors.New(msg) + } + return err + } + if out == nil { + return nil + } + return json.Unmarshal(b, out) +} + +func (s Sudo) List(ctx context.Context, path string) (Listing, error) { + var l Listing + err := s.runJSON(ctx, &l, "list", path) + return l, err +} + +func (s Sudo) Stat(ctx context.Context, path string) (Info, error) { + var in Info + err := s.runJSON(ctx, &in, "stat", path) + return in, err +} + +type cmdReader struct { + io.ReadCloser + cmd *exec.Cmd + errb *strings.Builder +} + +func (r *cmdReader) Close() error { + r.ReadCloser.Close() + err := r.cmd.Wait() + if err != nil && r.errb.Len() > 0 { + return errors.New(strings.TrimSpace(r.errb.String())) + } + return err +} + +func (s Sudo) Open(ctx context.Context, path string) (io.ReadCloser, error) { + c := s.cmd(ctx, "read", path) + errb := &strings.Builder{} + c.Stderr = errb + out, err := c.StdoutPipe() + if err != nil { + return nil, err + } + if err := c.Start(); err != nil { + return nil, err + } + // Fail early on "no such file" etc.: the helper stats before writing anything. + head := make([]byte, 1) + n, rerr := out.Read(head) + if n == 0 && rerr != nil { + _ = c.Wait() + if msg := strings.TrimSpace(errb.String()); msg != "" { + return nil, errors.New(msg) + } + if rerr != io.EOF { + return nil, rerr + } + } + return &cmdReader{ReadCloser: struct { + io.Reader + io.Closer + }{io.MultiReader(strings.NewReader(string(head[:n])), out), out}, cmd: c, errb: errb}, nil +} + +func (s Sudo) Write(ctx context.Context, path string, r io.Reader) error { + c := s.cmd(ctx, "write", path) + c.Stdin = r + var errb strings.Builder + c.Stderr = &errb + if err := c.Run(); err != nil { + if msg := strings.TrimSpace(errb.String()); msg != "" { + return errors.New(msg) + } + return err + } + return nil +} + +func (s Sudo) Mkdir(ctx context.Context, path string) error { + return s.runJSON(ctx, nil, "mkdir", path) +} +func (s Sudo) Touch(ctx context.Context, path string) error { + return s.runJSON(ctx, nil, "touch", path) +} +func (s Sudo) Rename(ctx context.Context, from, to string) error { + return s.runJSON(ctx, nil, "rename", from, to) +} +func (s Sudo) Delete(ctx context.Context, path string, recursive bool) error { + if recursive { + return s.runJSON(ctx, nil, "delete", "-r", path) + } + return s.runJSON(ctx, nil, "delete", path) +} +func (s Sudo) Chmod(ctx context.Context, path string, perm os.FileMode) error { + return s.runJSON(ctx, nil, "chmod", fmt.Sprintf("%04o", perm), path) +} + +// Helper implements `hamlab fs …` for the Sudo backend. Returns the exit code. +func Helper(args []string, stdin io.Reader, stdout io.Writer, stderr io.Writer) int { + fail := func(err error) int { fmt.Fprintln(stderr, err.Error()); return 1 } + if len(args) < 2 { + return fail(errors.New("usage: hamlab fs …")) + } + ctx := context.Background() + var l Local + op := args[0] + clean := func(i int) (string, error) { + if i >= len(args) { + return "", errors.New("path required") + } + return Clean(args[i]) + } + switch op { + case "list", "stat", "read", "write", "mkdir", "touch": + p, err := clean(1) + if err != nil { + return fail(err) + } + switch op { + case "list": + out, err := l.List(ctx, p) + if err != nil { + return fail(err) + } + return emit(stdout, out, stderr) + case "stat": + out, err := l.Stat(ctx, p) + if err != nil { + return fail(err) + } + return emit(stdout, out, stderr) + case "read": + f, err := l.Open(ctx, p) + if err != nil { + return fail(err) + } + defer f.Close() + if _, err := io.Copy(stdout, f); err != nil { + return fail(err) + } + return 0 + case "write": + if err := l.Write(ctx, p, stdin); err != nil { + return fail(err) + } + case "mkdir": + if err := l.Mkdir(ctx, p); err != nil { + return fail(err) + } + case "touch": + if err := l.Touch(ctx, p); err != nil { + return fail(err) + } + } + case "rename": + from, err := clean(1) + if err != nil { + return fail(err) + } + to, err := clean(2) + if err != nil { + return fail(err) + } + if err := l.Rename(ctx, from, to); err != nil { + return fail(err) + } + case "delete": + rec := false + i := 1 + if args[1] == "-r" { + rec, i = true, 2 + } + p, err := clean(i) + if err != nil { + return fail(err) + } + if err := l.Delete(ctx, p, rec); err != nil { + return fail(err) + } + case "chmod": + perm, err := ParsePerm(args[1]) + if err != nil { + return fail(err) + } + p, err := clean(2) + if err != nil { + return fail(err) + } + if err := l.Chmod(ctx, p, perm); err != nil { + return fail(err) + } + default: + return fail(errors.New("unknown op " + op)) + } + fmt.Fprintln(stdout, "{}") + return 0 +} + +func emit(w io.Writer, v any, stderr io.Writer) int { + if err := json.NewEncoder(w).Encode(v); err != nil { + fmt.Fprintln(stderr, err.Error()) + return 1 + } + return 0 +} + +// ModTime helper for handlers. +func Now() time.Time { return time.Now() } diff --git a/web/src/Files.tsx b/web/src/Files.tsx new file mode 100644 index 0000000..4c81c94 --- /dev/null +++ b/web/src/Files.tsx @@ -0,0 +1,233 @@ +import { DragEvent, useCallback, useEffect, useRef, useState } from 'react' +import { api, FileEntry, FileInfo } from './api' +import { fmtBytes } from './Dashboard' +import { Btn, Loading, Spinner, toast } from './ui' + +const join = (dir: string, name: string) => (dir === '/' ? '/' + name : dir + '/' + name) +const fmtTime = (ms: number) => { const d = new Date(ms); const now = new Date(); return d.toDateString() === now.toDateString() ? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : d.toLocaleDateString(undefined, { year: d.getFullYear() === now.getFullYear() ? undefined : 'numeric', month: 'short', day: '2-digit' }) } +const isImage = (n: string) => /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i.test(n) +const shellQuote = (s: string) => "'" + s.replace(/'/g, "'\\''") + "'" +const MAX_EDIT = 2 * 1024 * 1024 + +function icon(e: FileEntry): string { + if (e.type === 'dir') return e.target ? '🔗' : '📁' + if (e.type === 'link') return e.broken ? '⛓️‍💥' : '🔗' + if (e.type === 'other') return '⚙️' + if (isImage(e.name)) return '🖼️' + if (/\.(zip|gz|bz2|xz|zst|tar|tgz|7z|rar)$/i.test(e.name)) return '🗜️' + if (/\.(sh|bash|zsh|py|go|js|ts|tsx|rb|pl|php|rs|c|h|cpp|java|lua)$/i.test(e.name) || e.mode.includes('x')) return '📜' + if (/\.(log|txt|md|json|ya?ml|toml|ini|conf|cfg|env|service|timer)$/i.test(e.name)) return '📄' + return '📄' +} + +type Sort = 'name' | 'size' | 'mtime' + +// ---- editor / viewer ---- +function Viewer({ path, sudo, onClose, onChanged }: { path: string; sudo: boolean; onClose: () => void; onChanged: () => void }) { + const [info, setInfo] = useState(null) + const [text, setText] = useState(null) + const [orig, setOrig] = useState('') + const [err, setErr] = useState('') + const [saving, setSaving] = useState(false) + const name = path.split('/').pop() ?? path + const dirty = text !== null && text !== orig + + useEffect(() => { + setInfo(null); setText(null); setErr('') + let stop = false + ;(async () => { + try { + const i = await api.fileStat(path, sudo) + if (stop) return + setInfo(i) + if (i.text && i.size <= MAX_EDIT && !isImage(name)) { + const t = await api.fileRead(path, sudo) + if (stop) return + setText(t); setOrig(t) + } + } catch (e) { if (!stop) setErr(e instanceof Error ? e.message : 'failed') } + })() + return () => { stop = true } + }, [path, sudo, name]) + + const save = useCallback(async () => { + if (text === null || !dirty) return + setSaving(true) + try { await api.fileWrite(path, text, sudo); setOrig(text); toast(`${name} saved`); onChanged() } + catch (e) { toast(`save failed — ${e instanceof Error ? e.message : 'failed'}`, 'err') } + finally { setSaving(false) } + }, [text, dirty, path, sudo, name, onChanged]) + + useEffect(() => { + const h = (e: KeyboardEvent) => { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); save() } } + window.addEventListener('keydown', h) + return () => window.removeEventListener('keydown', h) + }, [save]) + + const close = () => { if (dirty && !window.confirm('Discard unsaved changes?')) return; onClose() } + const dl = api.fileDownloadURL(path, sudo) + + return ( +
+
+ + {name}{dirty && •} + + {info && {fmtBytes(info.size)} · {info.perm} · {info.user}} + Download + {text !== null && Save} +
+ {err &&
{err}
} + {!info && !err && } + {info && text !== null && ( +