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 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-18 05:39:57 -07:00
co-authored by Claude Fable 5.1
parent 38d4ee09ae
commit 63b020d556
9 changed files with 1094 additions and 9 deletions
+252 -2
View File
@@ -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 <path>/<name>.
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) {