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:
co-authored by
Claude Fable 5.1
parent
38d4ee09ae
commit
63b020d556
+252
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user