diff --git a/Makefile b/Makefile index a4e1060..ef750f5 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,5 @@ BINARY=nasctl -VERSION?=0.3.2 +VERSION?=0.4.0 GO?=go LDFLAGS=-s -w -X github.com/darroyo/nasctl/internal/web.Version=$(VERSION) -X github.com/darroyo/nasctl/internal/web.Commit=$(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_FLAGS=CGO_ENABLED=0 diff --git a/cmd/nasctl/main.go b/cmd/nasctl/main.go index 6759c1b..3af0fbd 100644 --- a/cmd/nasctl/main.go +++ b/cmd/nasctl/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strconv" "strings" "github.com/darroyo/nasctl/internal/db" @@ -27,6 +28,8 @@ func main() { adminUser := flag.String("admin-user", envOrDefault("NASCTL_ADMIN_USER", "admin"), "Initial admin username (only used if no admin exists)") adminPass := flag.String("admin-pass", envOrDefault("NASCTL_ADMIN_PASSWORD", "admin"), "Initial admin password (only used if no admin exists)") importOnBoot := flag.Bool("import-on-boot", envOrDefault("NASCTL_IMPORT_ON_BOOT", "false") == "true", "Import existing smb.conf and /etc/exports on first boot") + uploadMaxBytes := flag.Int64("upload-max-bytes", parseEnvInt64("NASCTL_UPLOAD_MAX_BYTES", 104857600), "Max bytes for file uploads") + previewMaxBytes := flag.Int64("preview-max-bytes", parseEnvInt64("NASCTL_PREVIEW_MAX_BYTES", 262144), "Max bytes for file previews") flag.Parse() if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil { @@ -80,10 +83,12 @@ func main() { eng := engine.New(sambaModule, nfsModule, usersModule) srv := web.NewServer(database, eng, web.Options{ - AllowedRoots: parseRoots(*allowedRoots), - Auth: auth, - SMBConfPath: *smbConfPath, - ExportsPath: *exportsPath, + AllowedRoots: parseRoots(*allowedRoots), + Auth: auth, + SMBConfPath: *smbConfPath, + ExportsPath: *exportsPath, + UploadMaxBytes: *uploadMaxBytes, + PreviewMaxBytes: *previewMaxBytes, }) log.Printf("nasctl %s (commit %s) listening on %s (db=%s exec-system=%v)", web.Version, web.Commit, *addr, *dbPath, *execSystem) @@ -113,3 +118,12 @@ func envOrDefault(key, fallback string) string { } return fallback } + +func parseEnvInt64(key string, fallback int64) int64 { + if value := os.Getenv(key); value != "" { + if v, err := strconv.ParseInt(value, 10, 64); err == nil { + return v + } + } + return fallback +} diff --git a/internal/files/fs.go b/internal/files/fs.go new file mode 100644 index 0000000..f223ca6 --- /dev/null +++ b/internal/files/fs.go @@ -0,0 +1,305 @@ +package files + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" + "syscall" + + "github.com/darroyo/nasctl/internal/system" +) + +type Entry struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Mode string `json:"mode"` + ModeNum uint32 `json:"mode_num"` + ModTime int64 `json:"mod_time"` +} + +type DirList struct { + Entries []Entry `json:"entries"` + Path string `json:"path"` + Total int `json:"total"` + Page int `json:"page"` + Limit int `json:"limit"` + HasMore bool `json:"has_more"` +} + +type FileInfo struct { + Path string `json:"path"` + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + Mode string `json:"mode"` + ModeNum uint32 `json:"mode_num"` + ModTime int64 `json:"mod_time"` + Uid uint32 `json:"uid"` + Gid uint32 `json:"gid"` + TotalBytes uint64 `json:"total_bytes,omitempty"` + FreeBytes uint64 `json:"free_bytes,omitempty"` +} + +type SearchHit struct { + Name string `json:"name"` + Path string `json:"path"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` +} + +type FileCapabilities struct { + Chmod bool `json:"chmod"` + Chown bool `json:"chown"` +} + +func List(path string, allowedRoots []string, page, limit int) (*DirList, error) { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return nil, err + } + info, err := os.Stat(path) + if err != nil { + return nil, fmt.Errorf("stat %q: %w", path, err) + } + if !info.IsDir() { + return nil, fmt.Errorf("path is not a directory: %q", path) + } + + entries, err := os.ReadDir(path) + if err != nil { + return nil, fmt.Errorf("read dir %q: %w", path, err) + } + + allEntries := make([]Entry, 0, len(entries)) + for _, e := range entries { + fullPath := filepath.Join(path, e.Name()) + fi, err := e.Info() + if err != nil { + continue + } + stat := fi.Sys().(*syscall.Stat_t) + allEntries = append(allEntries, Entry{ + Name: e.Name(), + Path: fullPath, + IsDir: e.IsDir(), + Size: fi.Size(), + Mode: fi.Mode().String(), + ModeNum: uint32(stat.Mode), + ModTime: fi.ModTime().Unix(), + }) + } + + sortEntries(allEntries) + + total := len(allEntries) + start := (page - 1) * limit + end := start + limit + if start > total { + start = total + } + if end > total { + end = total + } + + return &DirList{ + Entries: allEntries[start:end], + Path: path, + Total: total, + Page: page, + Limit: limit, + HasMore: end < total, + }, nil +} + +func Info(path string, allowedRoots []string) (*FileInfo, error) { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return nil, err + } + fi, err := os.Lstat(path) + if err != nil { + return nil, fmt.Errorf("lstat %q: %w", path, err) + } + stat := fi.Sys().(*syscall.Stat_t) + + info := &FileInfo{ + Name: fi.Name(), + Path: path, + IsDir: fi.IsDir(), + Size: fi.Size(), + Mode: fi.Mode().String(), + ModeNum: uint32(stat.Mode), + ModTime: fi.ModTime().Unix(), + Uid: stat.Uid, + Gid: stat.Gid, + } + + if fi.IsDir() { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err == nil { + info.TotalBytes = st.Blocks * uint64(st.Bsize) + info.FreeBytes = st.Bavail * uint64(st.Bsize) + } + } + + return info, nil +} + +func Mkdir(path string, allowedRoots []string) error { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return err + } + return os.MkdirAll(path, 0o755) +} + +func Rename(path, newName string, allowedRoots []string) error { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return err + } + if err := validateNewName(newName); err != nil { + return err + } + dir := filepath.Dir(path) + newPath := filepath.Join(dir, newName) + if err := system.ValidatePathAllowed(newPath, allowedRoots); err != nil { + return err + } + return os.Rename(path, newPath) +} + +func Delete(path string, allowedRoots []string) error { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return err + } + clean := filepath.Clean(path) + if clean == "/" { + return fmt.Errorf("refusing to delete root") + } + return os.RemoveAll(path) +} + +func Chmod(path string, mode os.FileMode, allowedRoots []string) error { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return err + } + return os.Chmod(path, mode) +} + +func Chown(path string, uid, gid int, allowedRoots []string) error { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return err + } + return os.Chown(path, uid, gid) +} + +func Statfs(path string) (total, free uint64, err error) { + var st syscall.Statfs_t + if err := syscall.Statfs(path, &st); err != nil { + return 0, 0, err + } + return st.Blocks * uint64(st.Bsize), st.Bavail * uint64(st.Bsize), nil +} + +func ReadFile(path string, max int64, allowedRoots []string) ([]byte, error) { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return nil, err + } + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + return io.ReadAll(io.LimitReader(f, max)) +} + +func WriteFile(path string, r io.Reader, max int64, allowedRoots []string) error { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644) + if err != nil { + return err + } + defer f.Close() + _, err = io.Copy(f, io.LimitReader(r, max)) + return err +} + +func Search(root, query string, limit int, allowedRoots []string) ([]SearchHit, error) { + if err := system.ValidatePathAllowed(root, allowedRoots); err != nil { + return nil, err + } + if query == "" { + return nil, nil + } + var hits []SearchHit + q := strings.ToLower(query) + walker := func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if strings.Contains(strings.ToLower(info.Name()), q) { + if err := system.ValidatePathAllowed(path, allowedRoots); err != nil { + return nil + } + hits = append(hits, SearchHit{ + Name: info.Name(), + Path: path, + IsDir: info.IsDir(), + Size: info.Size(), + }) + if len(hits) >= limit { + return filepath.SkipAll + } + } + return nil + } + _ = filepath.Walk(root, walker) + return hits, nil +} + +func Capabilities() FileCapabilities { + return FileCapabilities{ + Chmod: os.Geteuid() == 0, + Chown: os.Geteuid() == 0, + } +} + +func validateNewName(name string) error { + if name == "" || len(name) > 255 { + return fmt.Errorf("invalid name length") + } + if name == ".." || name == "." { + return fmt.Errorf("invalid name: %q", name) + } + if strings.ContainsRune(name, '/') { + return fmt.Errorf("name must not contain /") + } + if strings.HasPrefix(name, "-") { + return fmt.Errorf("name must not start with -") + } + if strings.Contains(name, "\x00") { + return fmt.Errorf("name must not contain null byte") + } + return nil +} + +func sortEntries(entries []Entry) { + for i := range entries { + for j := i + 1; j < len(entries); j++ { + di, dj := entries[i].IsDir, entries[j].IsDir + if di != dj { + if di { + continue + } + entries[i], entries[j] = entries[j], entries[i] + continue + } + if entries[i].Name > entries[j].Name { + entries[i], entries[j] = entries[j], entries[i] + } + } + } +} diff --git a/internal/files/fs_test.go b/internal/files/fs_test.go new file mode 100644 index 0000000..6f6e82e --- /dev/null +++ b/internal/files/fs_test.go @@ -0,0 +1,297 @@ +package files + +import ( + "os" + "path/filepath" + "syscall" + "testing" +) + +func TestList(t *testing.T) { + dir := t.TempDir() + + subdir := filepath.Join(dir, "subdir") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + f1 := filepath.Join(dir, "a.txt") + if err := os.WriteFile(f1, []byte("hello"), 0o644); err != nil { + t.Fatal(err) + } + f2 := filepath.Join(dir, "b.log") + if err := os.WriteFile(f2, []byte("world"), 0o644); err != nil { + t.Fatal(err) + } + + res, err := List(dir, nil, 1, 50) + if err != nil { + t.Fatalf("List: %v", err) + } + if res.Total != 3 { // subdir, a.txt, b.log + t.Errorf("Total=%d, want 3", res.Total) + } + if res.HasMore { + t.Errorf("HasMore=true, want false") + } +} + +func TestListPaged(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 10; i++ { + if err := os.WriteFile(filepath.Join(dir, string(rune('a'+i))+".txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + page1, err := List(dir, nil, 1, 3) + if err != nil { + t.Fatalf("page1: %v", err) + } + if len(page1.Entries) != 3 { + t.Errorf("page1 len=%d, want 3", len(page1.Entries)) + } + if !page1.HasMore { + t.Errorf("page1.HasMore=false, want true") + } + + page2, err := List(dir, nil, 2, 3) + if err != nil { + t.Fatalf("page2: %v", err) + } + if len(page2.Entries) != 3 { + t.Errorf("page2 len=%d, want 3", len(page2.Entries)) + } + + page4, err := List(dir, nil, 4, 3) + if err != nil { + t.Fatalf("page4: %v", err) + } + if len(page4.Entries) != 1 { + t.Errorf("page4 len=%d, want 1", len(page4.Entries)) + } + if page4.HasMore { + t.Errorf("page4.HasMore=true, want false") + } +} + +func TestListRejectsNotDir(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + _, err := List(f, nil, 1, 50) + if err == nil { + t.Error("expected error for non-directory") + } +} + +func TestInfo(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "test.txt") + if err := os.WriteFile(f, []byte("hello world"), 0o644); err != nil { + t.Fatal(err) + } + + info, err := Info(f, nil) + if err != nil { + t.Fatalf("Info: %v", err) + } + if info.Name != "test.txt" { + t.Errorf("Name=%q, want test.txt", info.Name) + } + if info.IsDir { + t.Errorf("IsDir=true, want false") + } + if info.Size != 11 { + t.Errorf("Size=%d, want 11", info.Size) + } +} + +func TestMkdir(t *testing.T) { + dir := t.TempDir() + newDir := filepath.Join(dir, "newdir", "nested") + if err := Mkdir(newDir, nil); err != nil { + t.Fatalf("Mkdir: %v", err) + } + info, err := os.Stat(newDir) + if err != nil { + t.Fatalf("stat: %v", err) + } + if !info.IsDir() { + t.Error("expected dir") + } +} + +func TestMkdirDisallowed(t *testing.T) { + dir := t.TempDir() + err := Mkdir(filepath.Join(dir, "ok"), []string{"/tmp"}) + if err == nil { + t.Error("expected error for disallowed path") + } +} + +func TestRename(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "old.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + if err := Rename(f, "new.txt", nil); err != nil { + t.Fatalf("Rename: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, "new.txt")); err != nil { + t.Errorf("new file not found: %v", err) + } + if _, err := os.Stat(f); err == nil { + t.Error("old file still exists") + } +} + +func TestRenameBadName(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "old.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + tests := []string{"/etc/passwd", "..", ".", "-f", ""} + for _, name := range tests { + err := Rename(f, name, nil) + if err == nil { + t.Errorf("Rename(%q): expected error", name) + } + } +} + +func TestDelete(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "todelete.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + if err := Delete(f, nil); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := os.Stat(f); err == nil { + t.Error("file still exists after delete") + } +} + +func TestDeleteRecursively(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "sub") + if err := os.MkdirAll(subdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(subdir, "f.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if err := Delete(subdir, nil); err != nil { + t.Fatalf("Delete: %v", err) + } + if _, err := os.Stat(subdir); err == nil { + t.Error("subdir still exists") + } +} + +func TestDeleteRejectsRoot(t *testing.T) { + err := Delete("/", nil) + if err == nil { + t.Error("expected error deleting root") + } +} + +func TestChmod(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + if err := Chmod(f, 0o600, nil); err != nil { + t.Fatalf("Chmod: %v", err) + } + info, _ := os.Stat(f) + if info.Mode().Perm() != 0o600 { + t.Errorf("mode=%o, want 600", info.Mode().Perm()) + } +} + +func TestChown(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "file.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + stat, _ := os.Stat(f) + st := stat.Sys().(*syscall.Stat_t) + if err := Chown(f, int(st.Uid), int(st.Gid), nil); err != nil { + t.Fatalf("Chown: %v", err) + } +} + +func TestSearch(t *testing.T) { + dir := t.TempDir() + sub := filepath.Join(dir, "photos") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + files := map[string]string{ + "myphoto.jpg": "xxx", + "photo_album.txt": "yyy", + "report.pdf": "zzz", + "data.csv": "aaa", + } + for name, content := range files { + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(sub, "vacation.png"), []byte("img"), 0o644); err != nil { + t.Fatal(err) + } + + hits, err := Search(dir, "photo", 50, nil) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(hits) < 2 { + t.Errorf("got %d hits, want at least 2", len(hits)) + } +} + +func TestSearchLimit(t *testing.T) { + dir := t.TempDir() + for i := 0; i < 20; i++ { + if err := os.WriteFile(filepath.Join(dir, "file_match_"+string(rune('a'+i))+".txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + + hits, err := Search(dir, "file_match", 5, nil) + if err != nil { + t.Fatalf("Search: %v", err) + } + if len(hits) != 5 { + t.Errorf("got %d hits, want 5", len(hits)) + } +} + +func TestValidatePathAllowed(t *testing.T) { + dir := t.TempDir() + f := filepath.Join(dir, "allowed.txt") + if err := os.WriteFile(f, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + + errs := []string{ + filepath.Join(dir, "..", "etc", "passwd"), + "/etc/passwd", + "/tmp/../../../secret", + } + for _, path := range errs { + _, err := List(path, []string{dir}, 1, 50) + if err == nil { + t.Errorf("expected error for %q", path) + } + } +} diff --git a/internal/files/preview.go b/internal/files/preview.go new file mode 100644 index 0000000..4ccf122 --- /dev/null +++ b/internal/files/preview.go @@ -0,0 +1,113 @@ +package files + +import ( + "bytes" + "net/http" + "path/filepath" + "strings" +) + +type PreviewResult struct { + Type string `json:"type"` + Content string `json:"content,omitempty"` + Mime string `json:"mime"` + Size int64 `json:"size"` +} + +var textMimes = map[string]bool{ + "text/plain": true, + "text/html": true, + "text/css": true, + "text/javascript": true, + "application/json": true, + "application/xml": true, + "application/xhtml+xml": true, + "application/javascript": true, +} + +var imageMimes = map[string]bool{ + "image/png": true, + "image/jpeg": true, + "image/gif": true, + "image/webp": true, + "image/svg+xml": true, + "image/bmp": true, + "image/tiff": true, +} + +func Preview(path string, max int64, allowedRoots []string) (*PreviewResult, error) { + data, err := ReadFile(path, max, allowedRoots) + if err != nil { + return nil, err + } + + mime := http.DetectContentType(data) + size := int64(len(data)) + + trimmed := bytes.TrimSpace(data) + if len(trimmed) > 0 { + mime = http.DetectContentType(trimmed) + } + + result := &PreviewResult{ + Mime: mime, + Size: size, + } + + if imageMimes[mime] || strings.HasPrefix(mime, "image/") { + result.Type = "image" + return result, nil + } + + if textMimes[mime] || strings.HasPrefix(mime, "text/") || isLikelyText(trimmed) { + result.Type = "text" + if size > 0 { + result.Content = string(data) + if int64(len(result.Content)) > max { + result.Content = result.Content[:max] + } + } + return result, nil + } + + result.Type = "binary" + return result, nil +} + +func isLikelyText(data []byte) bool { + if len(data) == 0 { + return false + } + nulls := 0 + for _, b := range data { + gb := int(b) + if (gb < 32 && gb != 9 && gb != 10 && gb != 13) || gb == 127 { + nulls++ + } + } + return float64(nulls)/float64(len(data)) < 0.1 +} + +func ExtensionIcon(name string) string { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".bmp", ".ico": + return "image" + case ".mp4", ".avi", ".mkv", ".mov", ".wmv", ".flv", ".webm": + return "video" + case ".mp3", ".wav", ".ogg", ".flac", ".aac", ".wma": + return "audio" + case ".zip", ".tar", ".gz", ".bz2", ".xz", ".rar", ".7z": + return "archive" + case ".pdf": + return "pdf" + case ".doc", ".docx", ".odt", ".rtf": + return "word" + case ".xls", ".xlsx", ".csv", ".ods": + return "spreadsheet" + case ".txt", ".md", ".log", ".cfg", ".conf", ".ini", ".yaml", ".yml", ".toml", ".json", ".xml": + return "text" + default: + return "file" + } +} diff --git a/internal/web/auth.go b/internal/web/auth.go index 4dd224d..5bef0dc 100644 --- a/internal/web/auth.go +++ b/internal/web/auth.go @@ -190,29 +190,41 @@ func (s *Server) RequireAuth(next http.Handler) http.Handler { } type Server struct { - DB *db.DB - Engine *engine.Engine - AllowedRoots []string - Auth *AuthService - SMBConfPath string - ExportsPath string + DB *db.DB + Engine *engine.Engine + AllowedRoots []string + Auth *AuthService + SMBConfPath string + ExportsPath string + UploadMaxBytes int64 + PreviewMaxBytes int64 } type Options struct { - AllowedRoots []string - Auth *AuthService - SMBConfPath string - ExportsPath string + AllowedRoots []string + Auth *AuthService + SMBConfPath string + ExportsPath string + UploadMaxBytes int64 + PreviewMaxBytes int64 } func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server { + if opts.UploadMaxBytes == 0 { + opts.UploadMaxBytes = 100 << 20 // 100 MB + } + if opts.PreviewMaxBytes == 0 { + opts.PreviewMaxBytes = 256 << 10 // 256 KB + } return &Server{ - DB: database, - Engine: eng, - AllowedRoots: opts.AllowedRoots, - Auth: opts.Auth, - SMBConfPath: opts.SMBConfPath, - ExportsPath: opts.ExportsPath, + DB: database, + Engine: eng, + AllowedRoots: opts.AllowedRoots, + Auth: opts.Auth, + SMBConfPath: opts.SMBConfPath, + ExportsPath: opts.ExportsPath, + UploadMaxBytes: opts.UploadMaxBytes, + PreviewMaxBytes: opts.PreviewMaxBytes, } } diff --git a/internal/web/handlers_files.go b/internal/web/handlers_files.go new file mode 100644 index 0000000..fc0c97f --- /dev/null +++ b/internal/web/handlers_files.go @@ -0,0 +1,313 @@ +package web + +import ( + "encoding/json" + "fmt" + "io" + "mime" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/darroyo/nasctl/internal/files" +) + +func (s *Server) handleCapabilities(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, files.Capabilities()) +} + +func (s *Server) handleListRoots(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "roots": s.AllowedRoots, + "unrestricted": len(s.AllowedRoots) == 0, + }) +} + +func (s *Server) handleList(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + if p == "" { + p = "/" + } + page, _ := strconv.Atoi(defaultQuery(r, "page", "1")) + limit, _ := strconv.Atoi(defaultQuery(r, "limit", "200")) + if page < 1 { + page = 1 + } + if limit < 1 || limit > 1000 { + limit = 200 + } + + res, err := files.List(p, s.AllowedRoots, page, limit) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, res) +} + +func (s *Server) handleInfo(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + if p == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + info, err := files.Info(p, s.AllowedRoots) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, info) +} + +func (s *Server) handleMkdir(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + } + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if req.Path == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + if err := files.Mkdir(req.Path, s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusCreated) +} + +func (s *Server) handleRename(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + NewName string `json:"newName"` + } + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if req.Path == "" || req.NewName == "" { + writeError(w, http.StatusBadRequest, "path and newName are required") + return + } + if err := files.Rename(req.Path, req.NewName, s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleChmod(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Mode string `json:"mode"` + } + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + modeNum, err := parseMode(req.Mode) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid mode: "+err.Error()) + return + } + if err := files.Chmod(req.Path, modeNum, s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleChown(w http.ResponseWriter, r *http.Request) { + var req struct { + Path string `json:"path"` + Uid int `json:"uid"` + Gid int `json:"gid"` + } + defer r.Body.Close() + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if err := files.Chown(req.Path, req.Uid, req.Gid, s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) handleDelete(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + if p == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + if err := files.Delete(p, s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func (s *Server) handleUpload(w http.ResponseWriter, r *http.Request) { + dir := r.URL.Query().Get("path") + if dir == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + if err := files.Mkdir(dir, s.AllowedRoots); err != nil { + _ = err + } + + r.Body = http.MaxBytesReader(w, r.Body, s.UploadMaxBytes) + defer r.Body.Close() + + if err := r.ParseMultipartForm(s.UploadMaxBytes); err != nil { + writeError(w, http.StatusRequestEntityTooLarge, "file too large") + return + } + + const fileField = "file" + file, header, err := r.FormFile(fileField) + if err != nil { + writeError(w, http.StatusBadRequest, "file field required") + return + } + defer file.Close() + + name := sanitizeFilename(header.Filename) + if name == "" { + name = "uploaded_file" + } + destPath := filepath.Join(dir, name) + + if err := files.WriteFile(destPath, file, s.UploadMaxBytes, s.AllowedRoots); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, map[string]string{"path": destPath}) +} + +func (s *Server) handleDownload(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + if p == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + + info, err := files.Info(p, s.AllowedRoots) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if info.IsDir { + writeError(w, http.StatusBadRequest, "cannot download directory") + return + } + + f, err := os.Open(p) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + defer f.Close() + + w.Header().Set("Content-Type", mime.TypeByExtension(filepath.Ext(p))) + w.Header().Set("Content-Disposition", `attachment; filename="`+sanitizeFilename(info.Name)+`"`) + w.Header().Set("Content-Length", strconv.FormatInt(info.Size, 10)) + io.Copy(w, f) +} + +func (s *Server) handlePreview(w http.ResponseWriter, r *http.Request) { + p := r.URL.Query().Get("path") + if p == "" { + writeError(w, http.StatusBadRequest, "path is required") + return + } + + info, err := files.Info(p, s.AllowedRoots) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + if info.IsDir { + writeError(w, http.StatusBadRequest, "cannot preview directory") + return + } + + result, err := files.Preview(p, s.PreviewMaxBytes, s.AllowedRoots) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, result) +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + root := r.URL.Query().Get("path") + if root == "" { + root = "/" + } + q := r.URL.Query().Get("q") + if q == "" { + writeError(w, http.StatusBadRequest, "q is required") + return + } + limit, _ := strconv.Atoi(defaultQuery(r, "limit", "100")) + if limit < 1 || limit > 500 { + limit = 100 + } + + hits, err := files.Search(root, q, limit, s.AllowedRoots) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{"results": hits}) +} + +func defaultQuery(r *http.Request, key, fallback string) string { + if v := r.URL.Query().Get(key); v != "" { + return v + } + return fallback +} + +func parseMode(s string) (os.FileMode, error) { + if s == "" { + return 0, fmt.Errorf("empty mode") + } + var mode uint64 + if strings.HasPrefix(s, "0") { + _, err := fmt.Sscanf(s, "%o", &mode) + if err != nil { + return 0, err + } + } else { + _, err := fmt.Sscanf(s, "%d", &mode) + if err != nil { + return 0, err + } + } + return os.FileMode(mode), nil +} + +func sanitizeFilename(name string) string { + name = filepath.Base(name) + name = strings.ReplaceAll(name, "\x00", "") + name = strings.TrimSpace(name) + if name == "" || name == "." || name == ".." { + name = "file" + } + if strings.Contains(name, "/") { + name = filepath.Base(name) + } + return name +} diff --git a/internal/web/router.go b/internal/web/router.go index 6118ef4..5a213ca 100644 --- a/internal/web/router.go +++ b/internal/web/router.go @@ -69,6 +69,22 @@ func NewRouter(s *Server) chi.Router { item.Delete("/", s.handleDeleteUser) }) }) + + protected.Route("/files", func(files chi.Router) { + files.Get("/capabilities", s.handleCapabilities) + files.Get("/roots", s.handleListRoots) + files.Get("/", s.handleList) + files.Get("/info", s.handleInfo) + files.Post("/mkdir", s.handleMkdir) + files.Post("/rename", s.handleRename) + files.Post("/chmod", s.handleChmod) + files.Post("/chown", s.handleChown) + files.Delete("/", s.handleDelete) + files.Post("/upload", s.handleUpload) + files.Get("/download", s.handleDownload) + files.Get("/preview", s.handlePreview) + files.Get("/search", s.handleSearch) + }) }) }) diff --git a/web/src/App.tsx b/web/src/App.tsx index e4af253..602ba3f 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -10,6 +10,7 @@ import Samba from "./pages/Samba"; import Nfs from "./pages/Nfs"; import Log from "./pages/Log"; import Settings from "./pages/Settings"; +import Files from "./pages/Files"; type AuthState = { loading: boolean; authenticated: boolean; username: string }; @@ -52,6 +53,7 @@ export default function App() { > } /> } /> + } /> } /> } /> } /> diff --git a/web/src/api.ts b/web/src/api.ts index 5018920..1f86a73 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -75,6 +75,58 @@ export interface VersionInfo { commit: string; } +export interface FileEntry { + name: string; + path: string; + is_dir: boolean; + size: number; + mode: string; + mode_num: number; + mod_time: number; +} + +export interface FileInfo { + path: string; + name: string; + is_dir: boolean; + size: number; + mode: string; + mode_num: number; + mod_time: number; + uid: number; + gid: number; + total_bytes?: number; + free_bytes?: number; +} + +export interface DirList { + entries: FileEntry[]; + path: string; + total: number; + page: number; + limit: number; + has_more: boolean; +} + +export interface SearchHit { + name: string; + path: string; + is_dir: boolean; + size: number; +} + +export interface FileCapabilities { + chmod: boolean; + chown: boolean; +} + +export interface FilePreview { + type: "text" | "image" | "binary"; + content?: string; + mime: string; + size: number; +} + export class ApiError extends Error { status: number; constructor(status: number, message: string) { @@ -84,10 +136,11 @@ export class ApiError extends Error { } async function request(method: string, path: string, body?: unknown): Promise { + const isFormData = body instanceof FormData; const res = await fetch(`/api${path}`, { method, - headers: body ? { "Content-Type": "application/json" } : undefined, - body: body ? JSON.stringify(body) : undefined, + headers: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined, + body: isFormData ? body : body ? JSON.stringify(body) : undefined, }); if (res.status === 204) { return undefined as T; @@ -138,6 +191,31 @@ export const api = { listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"), createWatchedMount: (path: string) => request("POST", "/system/watched-mounts", { path }), deleteWatchedMount: (id: number) => request("DELETE", `/system/watched-mounts/${id}`), + + // files + fileCapabilities: () => request("GET", "/files/capabilities"), + fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"), + listFiles: (path: string, page = 1, limit = 200) => + request("GET", `/files/?path=${encodeURIComponent(path)}&page=${page}&limit=${limit}`), + fileInfo: (path: string) => request("GET", `/files/info?path=${encodeURIComponent(path)}`), + mkdirFile: (path: string) => request("POST", "/files/mkdir", { path }), + renameFile: (path: string, newName: string) => + request("POST", "/files/rename", { path, newName }), + chmodFile: (path: string, mode: string) => + request("POST", "/files/chmod", { path, mode }), + chownFile: (path: string, uid: number, gid: number) => + request("POST", "/files/chown", { path, uid, gid }), + deleteFile: (path: string) => request("DELETE", `/files/?path=${encodeURIComponent(path)}`), + uploadFile: (dir: string, file: File) => { + const fd = new FormData(); + fd.append("file", file); + return request<{ path: string }>("POST", `/files/upload?path=${encodeURIComponent(dir)}`, fd); + }, + filePreview: (path: string) => + request("GET", `/files/preview?path=${encodeURIComponent(path)}`), + searchFiles: (path: string, q: string, limit = 100) => + request<{ results: SearchHit[] }>(`GET`, `/files/search?path=${encodeURIComponent(path)}&q=${encodeURIComponent(q)}&limit=${limit}`), + fileDownloadUrl: (path: string) => `/api/files/download?path=${encodeURIComponent(path)}`, }; export function formatBytes(bytes: number): string { diff --git a/web/src/components/FileBrowserModal.tsx b/web/src/components/FileBrowserModal.tsx new file mode 100644 index 0000000..ed3afe1 --- /dev/null +++ b/web/src/components/FileBrowserModal.tsx @@ -0,0 +1,328 @@ +import { useEffect, useState, useRef, DragEvent } from "react"; +import Modal from "./Modal"; +import { api, FileEntry, FileCapabilities, formatBytes } from "../api"; + +function FileIcon({ name }: { name: string }) { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + if (name === "..") return ; + if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "gif" || ext === "webp" || ext === "svg") + return 🖼; + if (ext === "mp4" || ext === "avi" || ext === "mkv" || ext === "mov") + return 🎬; + if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac") + return 🎵; + if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z") + return 📦; + if (ext === "pdf") + return 📄; + if (ext === "txt" || ext === "md" || ext === "log" || ext === "cfg" || ext === "conf" || ext === "json" || ext === "yaml" || ext === "yml" || ext === "toml" || ext === "xml") + return 📝; + if (name.includes("/")) return 📁; + return 📄; +} + +interface Props { + initialPath?: string; + onSelect: (path: string) => void; + onClose: () => void; +} + +export default function FileBrowserModal({ initialPath, onSelect, onClose }: Props) { + const [roots, setRoots] = useState([]); + const [currentPath, setCurrentPath] = useState(initialPath ?? "/"); + const [entries, setEntries] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [capabilities, setCapabilities] = useState(null); + const [showMkdir, setShowMkdir] = useState(false); + const [mkdirName, setMkdirName] = useState(""); + const [renameState, setRenameState] = useState<{ path: string; name: string } | null>(null); + const [selectedPath, setSelectedPath] = useState(null); + const fileInputRef = useRef(null); + const LIMIT = 200; + + useEffect(() => { + api.fileCapabilities().then(setCapabilities).catch(() => {}); + api.fileRoots().then(r => { + setRoots(r.roots); + if (!initialPath && r.roots.length > 0) { + setCurrentPath(r.roots[0]); + } + }).catch(() => {}); + }, []); + + useEffect(() => { + loadDir(currentPath, 1); + }, [currentPath]); + + async function loadDir(path: string, pageNum: number) { + setLoading(true); + setError(null); + try { + const res = await api.listFiles(path, pageNum, LIMIT); + if (pageNum === 1) { + setEntries(res.entries); + } else { + setEntries(prev => [...prev, ...res.entries]); + } + setPage(pageNum); + setHasMore(res.has_more); + setTotal(res.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Error loading directory"); + } finally { + setLoading(false); + } + } + + function refresh() { + loadDir(currentPath, 1); + } + + async function navigateTo(path: string) { + setCurrentPath(path); + setSelectedPath(null); + } + + function handleDoubleClick(entry: FileEntry) { + if (entry.is_dir) { + navigateTo(entry.path); + } else { + setSelectedPath(entry.path); + } + } + + async function handleMkdir() { + if (!mkdirName.trim()) return; + try { + await api.mkdirFile(currentPath + "/" + mkdirName.trim()); + setMkdirName(""); + setShowMkdir(false); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error creating folder"); + } + } + + async function handleRename() { + if (!renameState) return; + try { + await api.renameFile(renameState.path, renameState.name); + setRenameState(null); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error renaming"); + } + } + + async function handleDelete(entry: FileEntry) { + if (!confirm(`¿Eliminar "${entry.name}"${entry.is_dir ? " y su contenido" : ""}?`)) return; + try { + await api.deleteFile(entry.path); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error deleting"); + } + } + + async function handleUpload(fileList: FileList | null) { + if (!fileList) return; + for (const file of Array.from(fileList)) { + try { + await api.uploadFile(currentPath, file); + } catch (e) { + setError(e instanceof Error ? e.message : "Error uploading"); + } + } + refresh(); + } + + function handleDropZone(e: DragEvent) { + e.preventDefault(); + e.stopPropagation(); + handleUpload(e.dataTransfer.files); + } + + const parentDir = currentPath === "/" ? null : (() => { + const parts = currentPath.split("/").filter(Boolean); + parts.pop(); + return "/" + parts.join("/"); + })(); + + const breadcrumbs = currentPath.split("/").filter(Boolean).map((part, i, arr) => { + const path = "/" + arr.slice(0, i + 1).join("/"); + return { part, path }; + }); + + return ( + +
+ {error && ( +
{error}
+ )} + +
+ {roots.length > 1 && ( + + )} +
+ + {breadcrumbs.map((b, i) => ( + + + {i < breadcrumbs.length - 1 && /} + + ))} +
+
+ +
+ + + handleUpload(e.target.files)} + /> + + +
+ +
e.preventDefault()} + onDrop={handleDropZone} + > + + + + + + + + + + + {parentDir && ( + navigateTo(parentDir!)} + > + + + + + + )} + {entries.map(entry => ( + setSelectedPath(entry.path)} + onDoubleClick={() => handleDoubleClick(entry)} + > + + + + + + ))} + {entries.length === 0 && !loading && ( + + + + )} + {loading && ( + + + + )} + +
NombreTamañoModificado
..
+ + {entry.name} + + {entry.is_dir ? "—" : formatBytes(entry.size)} + + {entry.mod_time > 0 ? new Date(entry.mod_time * 1000).toLocaleDateString() : "—"} + +
+ {capabilities?.chmod && ( + + )} + +
+
+ Directorio vacío +
+ Cargando... +
+
+ + {hasMore && ( + + )} + + {showMkdir && ( +
+ setMkdirName(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleMkdir()} + autoFocus + /> + + +
+ )} + + {renameState && ( +
+ setRenameState({ ...renameState, name: e.target.value })} + onKeyDown={e => e.key === "Enter" && handleRename()} + autoFocus + /> + + +
+ )} +
+
+ ); +} diff --git a/web/src/components/Layout.tsx b/web/src/components/Layout.tsx index d1d9490..b101283 100644 --- a/web/src/components/Layout.tsx +++ b/web/src/components/Layout.tsx @@ -6,6 +6,7 @@ import DirtyBanner from "./DirtyBanner"; const navItems = [ { to: "/", label: "Dashboard", end: true }, { to: "/users", label: "Usuarios" }, + { to: "/files", label: "Archivos" }, { to: "/samba", label: "SMB / Samba" }, { to: "/nfs", label: "NFS" }, { to: "/log", label: "Historial" }, diff --git a/web/src/components/Modal.tsx b/web/src/components/Modal.tsx index 451aa3d..aa59b05 100644 --- a/web/src/components/Modal.tsx +++ b/web/src/components/Modal.tsx @@ -1,18 +1,31 @@ import { ReactNode } from "react"; +const widthMap: Record = { + sm: "max-w-sm", + md: "max-w-md", + lg: "max-w-lg", + xl: "max-w-xl", + "2xl": "max-w-2xl", + "3xl": "max-w-3xl", + "4xl": "max-w-4xl", + full: "max-w-full", +}; + export default function Modal({ title, onClose, children, + maxWidth = "lg", }: { title: string; onClose: () => void; children: ReactNode; + maxWidth?: keyof typeof widthMap; }) { return (
e.stopPropagation()} >
diff --git a/web/src/components/PathField.tsx b/web/src/components/PathField.tsx new file mode 100644 index 0000000..d68b9a0 --- /dev/null +++ b/web/src/components/PathField.tsx @@ -0,0 +1,38 @@ +import { useState } from "react"; +import FileBrowserModal from "./FileBrowserModal"; + +interface Props { + value: string; + onChange: (path: string) => void; +} + +export default function PathField({ value, onChange }: Props) { + const [open, setOpen] = useState(false); + + return ( +
+
+ onChange(e.target.value)} + placeholder="/ruta/absoluta" + /> + +
+ {open && ( + { onChange(p); setOpen(false); }} + onClose={() => setOpen(false)} + /> + )} +
+ ); +} diff --git a/web/src/pages/Files.tsx b/web/src/pages/Files.tsx new file mode 100644 index 0000000..5c4fa5a --- /dev/null +++ b/web/src/pages/Files.tsx @@ -0,0 +1,649 @@ +import { useEffect, useState, useRef, DragEvent, useCallback } from "react"; +import { api, FileEntry, FileCapabilities, FilePreview, SearchHit, formatBytes } from "../api"; +import Modal from "../components/Modal"; + +function FileIcon({ name }: { name: string }) { + const ext = name.split(".").pop()?.toLowerCase() ?? ""; + if (name === "..") return ; + if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "gif" || ext === "webp" || ext === "svg") + return 🖼; + if (ext === "mp4" || ext === "avi" || ext === "mkv" || ext === "mov") + return 🎬; + if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac") + return 🎵; + if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z") + return 📦; + if (ext === "pdf") + return 📄; + if (ext === "txt" || ext === "md" || ext === "log" || ext === "cfg" || ext === "conf" || ext === "json" || ext === "yaml" || ext === "yml" || ext === "toml" || ext === "xml") + return 📝; + return 📄; +} + +function modeStr(mode: number): string { + const perm = (mode & 0o777).toString(8).padStart(3, "0"); + const type = mode & 0o170000; + if (type === 0o40000) return `drwxr-xr-x`.slice(0, 10 - perm.length) + perm; + if (type === 0o120000) return `lrwxr-xr-x`.slice(0, 10 - perm.length) + perm; + return `-rwxr-xr-x`.slice(0, 10 - perm.length) + perm; +} + +export default function Files() { + const [roots, setRoots] = useState([]); + const [unrestricted, setUnrestricted] = useState(false); + const [currentPath, setCurrentPath] = useState("/"); + const [entries, setEntries] = useState([]); + const [page, setPage] = useState(1); + const [hasMore, setHasMore] = useState(false); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [capabilities, setCapabilities] = useState(null); + + const [searchQuery, setSearchQuery] = useState(""); + const [searchResults, setSearchResults] = useState([]); + const [searching, setSearching] = useState(false); + + const [showMkdir, setShowMkdir] = useState(false); + const [mkdirName, setMkdirName] = useState(""); + + const [selectedEntry, setSelectedEntry] = useState(null); + const [preview, setPreview] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [showPreview, setShowPreview] = useState(false); + + const [renameState, setRenameState] = useState<{ entry: FileEntry; name: string } | null>(null); + const [chmodState, setChmodState] = useState<{ entry: FileEntry; mode: string } | null>(null); + const [chownState, setChownState] = useState<{ entry: FileEntry; uid: string; gid: string } | null>(null); + + const [isDragOver, setIsDragOver] = useState(false); + const [uploading, setUploading] = useState(false); + + const fileInputRef = useRef(null); + const LIMIT = 200; + + useEffect(() => { + api.fileCapabilities().then(setCapabilities).catch(() => {}); + api.fileRoots().then(r => { + setRoots(r.roots); + setUnrestricted(r.unrestricted); + if (r.roots.length > 0) { + setCurrentPath(r.roots[0]); + } else if (!r.unrestricted) { + setCurrentPath("/"); + } + }).catch(() => {}); + }, []); + + useEffect(() => { + if (currentPath) { + loadDir(currentPath, 1); + } + }, [currentPath]); + + async function loadDir(path: string, pageNum: number) { + setLoading(true); + setError(null); + setSearchResults([]); + setSearchQuery(""); + try { + const res = await api.listFiles(path, pageNum, LIMIT); + if (pageNum === 1) { + setEntries(res.entries); + } else { + setEntries(prev => [...prev, ...res.entries]); + } + setPage(pageNum); + setHasMore(res.has_more); + setTotal(res.total); + } catch (e) { + setError(e instanceof Error ? e.message : "Error loading directory"); + } finally { + setLoading(false); + } + } + + function refresh() { + loadDir(currentPath, 1); + } + + function navigateTo(path: string) { + setSelectedEntry(null); + setPreview(null); + setShowPreview(false); + setCurrentPath(path); + } + + function handleDoubleClick(entry: FileEntry) { + if (entry.is_dir) { + navigateTo(entry.path); + } else { + openPreview(entry); + } + } + + async function openPreview(entry: FileEntry) { + setSelectedEntry(entry); + setPreviewLoading(true); + setShowPreview(true); + try { + const p = await api.filePreview(entry.path); + setPreview(p); + } catch (e) { + setPreview({ type: "binary", mime: "", size: entry.size }); + } finally { + setPreviewLoading(false); + } + } + + function parentDir(): string | null { + if (currentPath === "/" || currentPath === "") return null; + const parts = currentPath.split("/").filter(Boolean); + parts.pop(); + const parent = "/" + parts.join("/"); + return parent === "/" ? "/" : parent; + } + + const breadcrumbs = currentPath.split("/").filter(Boolean).map((part, i, arr) => { + const path = "/" + arr.slice(0, i + 1).join("/"); + return { part, path }; + }); + + async function handleMkdir() { + if (!mkdirName.trim()) return; + try { + await api.mkdirFile(currentPath + "/" + mkdirName.trim()); + setMkdirName(""); + setShowMkdir(false); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error creating folder"); + } + } + + async function handleRename() { + if (!renameState) return; + try { + await api.renameFile(renameState.entry.path, renameState.name); + setRenameState(null); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error renaming"); + } + } + + async function handleChmod() { + if (!chmodState) return; + try { + await api.chmodFile(chmodState.entry.path, chmodState.mode); + setChmodState(null); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error changing permissions"); + } + } + + async function handleChown() { + if (!chownState) return; + const uid = parseInt(chownState.uid, 10); + const gid = parseInt(chownState.gid, 10); + if (isNaN(uid) || isNaN(gid)) { + setError("UID y GID deben ser numéricos"); + return; + } + try { + await api.chownFile(chownState.entry.path, uid, gid); + setChownState(null); + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error changing owner"); + } + } + + async function handleDelete(entry: FileEntry) { + if (!confirm(`¿Eliminar "${entry.name}"${entry.is_dir ? " y todo su contenido" : ""}?`)) return; + try { + await api.deleteFile(entry.path); + if (selectedEntry?.path === entry.path) { + setSelectedEntry(null); + setPreview(null); + setShowPreview(false); + } + refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Error deleting"); + } + } + + async function handleUpload(fileList: FileList | null) { + if (!fileList || fileList.length === 0) return; + setUploading(true); + for (const file of Array.from(fileList)) { + try { + await api.uploadFile(currentPath, file); + } catch (e) { + setError(e instanceof Error ? e.message : `Error uploading ${file.name}`); + } + } + setUploading(false); + refresh(); + } + + async function doSearch(q: string) { + if (!q.trim()) { + setSearchResults([]); + return; + } + setSearching(true); + try { + const res = await api.searchFiles(currentPath, q, 100); + setSearchResults(res.results); + } catch (e) { + setError(e instanceof Error ? e.message : "Error searching"); + } finally { + setSearching(false); + } + } + + const handleSearchSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + doSearch(searchQuery); + }, [searchQuery, currentPath]); + + function handleDropZone(e: DragEvent) { + e.preventDefault(); + setIsDragOver(false); + handleUpload(e.dataTransfer.files); + } + + function handleDragOver(e: DragEvent) { + e.preventDefault(); + setIsDragOver(true); + } + + function handleDragLeave() { + setIsDragOver(false); + } + + function selectSearchResult(hit: SearchHit) { + const dir = hit.path.substring(0, hit.path.lastIndexOf("/")) || "/"; + navigateTo(dir); + setSelectedEntry({ name: hit.name, path: hit.path, is_dir: hit.is_dir, size: hit.size, mode: "", mode_num: 0, mod_time: 0 }); + } + + return ( +
+
+

Archivos

+
+ +
+
+ + {error && ( +
{error}
+ )} + +
+ {roots.length > 1 && ( + + )} + {unrestricted && ( + + Sin restricción — acceso a todo el FS + + )} +
+ + {breadcrumbs.map((b, i) => ( + + + {i < breadcrumbs.length - 1 && /} + + ))} +
+
+ +
+ setSearchQuery(e.target.value)} + /> + +
+ + {searchResults.length > 0 && ( +
+
Resultados ({searchResults.length})
+
+ {searchResults.map((hit, i) => ( +
selectSearchResult(hit)} + onDoubleClick={() => { + if (hit.is_dir) { + navigateTo(hit.path); + } else { + setSelectedEntry({ name: hit.name, path: hit.path, is_dir: hit.is_dir, size: hit.size, mode: "", mode_num: 0, mod_time: 0 }); + openPreview({ name: hit.name, path: hit.path, is_dir: hit.is_dir, size: hit.size, mode: "", mode_num: 0, mod_time: 0 }); + } + }} + > + + {hit.path} + {hit.is_dir ? "carpeta" : formatBytes(hit.size)} +
+ ))} +
+ +
+ )} + +
+ + handleUpload(e.target.files)} /> + + {capabilities?.chown && ( + + )} + {capabilities?.chmod && ( + + )} +
+ +
+ {isDragOver && ( +
+ Suelta para subir +
+ )} + + + + + + + + + + + + {parentDir() !== null && ( + navigateTo(parentDir()!)} + > + + + + + + + )} + {entries.map(entry => ( + setSelectedEntry(entry)} + onDoubleClick={() => handleDoubleClick(entry)} + > + + + + + + + ))} + {entries.length === 0 && !loading && ( + + + + )} + {loading && ( + + + + )} + +
NombreTamañoPermisosModificado
..
+
+ + {entry.name} +
+
+ {entry.is_dir ? "—" : formatBytes(entry.size)} + + {modeStr(entry.mode_num)} + + {entry.mod_time > 0 ? new Date(entry.mod_time * 1000).toLocaleString() : "—"} + +
+ + {capabilities?.chmod && ( + + )} + {capabilities?.chown && ( + + )} + +
+
+ {searchQuery ? "Sin resultados" : "Directorio vacío"} +
+ Cargando… +
+
+ + {hasMore && ( + + )} + + {showMkdir && ( + setShowMkdir(false)} maxWidth="sm"> +
+ setMkdirName(e.target.value)} + onKeyDown={e => e.key === "Enter" && handleMkdir()} + autoFocus + /> + +
+
+ )} + + {renameState && ( + setRenameState(null)} maxWidth="sm"> +
+ setRenameState({ ...renameState, name: e.target.value })} + onKeyDown={e => e.key === "Enter" && handleRename()} + autoFocus + /> + +
+
+ )} + + {chmodState && ( + setChmodState(null)} maxWidth="sm"> +
+
+ setChmodState({ ...chmodState, mode: e.target.value })} + onKeyDown={e => e.key === "Enter" && handleChmod()} + autoFocus + placeholder="755" + /> + octal (ej: 755, 644) +
+
+ {(parseInt(chmodState.mode, 8) || 0).toString(8).padStart(3, "0")} = {[ + ["r", (parseInt(chmodState.mode, 8) || 0) & 0o400], + ["w", (parseInt(chmodState.mode, 8) || 0) & 0o200], + ["x", (parseInt(chmodState.mode, 8) || 0) & 0o100], + ].map(([c, v]) => c + (v ? "✓" : "✗")).join(" ")} +
+
+ + +
+
+
+ )} + + {chownState && ( + setChownState(null)} maxWidth="sm"> +
+
+ + setChownState({ ...chownState, uid: e.target.value })} + placeholder="0" + /> +
+
+ + setChownState({ ...chownState, gid: e.target.value })} + placeholder="0" + /> +
+
+ + +
+
+
+ )} + + {showPreview && selectedEntry && ( + { setShowPreview(false); setPreview(null); }} + maxWidth="3xl" + > +
+ {previewLoading ? ( +
Cargando…
+ ) : preview?.type === "text" ? ( +
+
{preview.mime} · {formatBytes(preview.size)}
+
+                  {preview.content}
+                
+
+ ) : preview?.type === "image" ? ( +
+
{preview.mime} · {formatBytes(preview.size)}
+ {selectedEntry.name} +
+ ) : ( +
+
Archivo binario
+
{preview?.mime} · {formatBytes(preview?.size ?? 0)}
+ + ⬇ Descargar + +
+ )} + {!previewLoading && ( +
+ + ⬇ Descargar + + +
+ )} +
+
+ )} +
+ ); +} diff --git a/web/src/pages/Nfs.tsx b/web/src/pages/Nfs.tsx index 11fd3c4..26f62b1 100644 --- a/web/src/pages/Nfs.tsx +++ b/web/src/pages/Nfs.tsx @@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react"; import { api, NFSExport } from "../api"; import { useDirty } from "../DirtyContext"; import Modal from "../components/Modal"; +import PathField from "../components/PathField"; const empty: Partial = { path: "", @@ -160,10 +161,9 @@ export default function Nfs() { )}
- setEditing({ ...editing, path: e.target.value })} + onChange={p => setEditing({ ...editing, path: p })} />
diff --git a/web/src/pages/Samba.tsx b/web/src/pages/Samba.tsx index 36711fb..a5e42bb 100644 --- a/web/src/pages/Samba.tsx +++ b/web/src/pages/Samba.tsx @@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react"; import { api, SambaShare } from "../api"; import { useDirty } from "../DirtyContext"; import Modal from "../components/Modal"; +import PathField from "../components/PathField"; const empty: Partial = { name: "", @@ -119,10 +120,9 @@ export default function Samba() {
- setEditing({ ...editing, path: e.target.value })} + onChange={p => setEditing({ ...editing, path: p })} />