feat: file manager with browse, search, upload, chmod/chown
- Full file browser page at /files with lazy-load, breadcrumbs, drag&drop - FileBrowserModal component for path selection from Samba/NFS forms - PathField component replaces bare inputs in share/export forms - Backend: /api/files/* routes with List, Mkdir, Rename, Delete, Chmod, Chown, Upload, Download, Preview, Search - Reuses NASCTL_ALLOWED_ROOTS for path validation - NASCTL_UPLOAD_MAX_BYTES (100MB) and NASCTL_PREVIEW_MAX_BYTES (256KB) env vars - Capabilities endpoint returns chmod/chown availability (requires root) - Version bump: 0.3.2 -> 0.4.0
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
BINARY=nasctl
|
BINARY=nasctl
|
||||||
VERSION?=0.3.2
|
VERSION?=0.4.0
|
||||||
GO?=go
|
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)
|
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
|
BUILD_FLAGS=CGO_ENABLED=0
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/darroyo/nasctl/internal/db"
|
"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)")
|
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)")
|
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")
|
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()
|
flag.Parse()
|
||||||
|
|
||||||
if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(*dbPath), 0o755); err != nil {
|
||||||
@@ -84,6 +87,8 @@ func main() {
|
|||||||
Auth: auth,
|
Auth: auth,
|
||||||
SMBConfPath: *smbConfPath,
|
SMBConfPath: *smbConfPath,
|
||||||
ExportsPath: *exportsPath,
|
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)
|
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
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -196,6 +196,8 @@ type Server struct {
|
|||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
SMBConfPath string
|
SMBConfPath string
|
||||||
ExportsPath string
|
ExportsPath string
|
||||||
|
UploadMaxBytes int64
|
||||||
|
PreviewMaxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
type Options struct {
|
type Options struct {
|
||||||
@@ -203,9 +205,17 @@ type Options struct {
|
|||||||
Auth *AuthService
|
Auth *AuthService
|
||||||
SMBConfPath string
|
SMBConfPath string
|
||||||
ExportsPath string
|
ExportsPath string
|
||||||
|
UploadMaxBytes int64
|
||||||
|
PreviewMaxBytes int64
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
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{
|
return &Server{
|
||||||
DB: database,
|
DB: database,
|
||||||
Engine: eng,
|
Engine: eng,
|
||||||
@@ -213,6 +223,8 @@ func NewServer(database *db.DB, eng *engine.Engine, opts Options) *Server {
|
|||||||
Auth: opts.Auth,
|
Auth: opts.Auth,
|
||||||
SMBConfPath: opts.SMBConfPath,
|
SMBConfPath: opts.SMBConfPath,
|
||||||
ExportsPath: opts.ExportsPath,
|
ExportsPath: opts.ExportsPath,
|
||||||
|
UploadMaxBytes: opts.UploadMaxBytes,
|
||||||
|
PreviewMaxBytes: opts.PreviewMaxBytes,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -69,6 +69,22 @@ func NewRouter(s *Server) chi.Router {
|
|||||||
item.Delete("/", s.handleDeleteUser)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Samba from "./pages/Samba";
|
|||||||
import Nfs from "./pages/Nfs";
|
import Nfs from "./pages/Nfs";
|
||||||
import Log from "./pages/Log";
|
import Log from "./pages/Log";
|
||||||
import Settings from "./pages/Settings";
|
import Settings from "./pages/Settings";
|
||||||
|
import Files from "./pages/Files";
|
||||||
|
|
||||||
type AuthState = { loading: boolean; authenticated: boolean; username: string };
|
type AuthState = { loading: boolean; authenticated: boolean; username: string };
|
||||||
|
|
||||||
@@ -52,6 +53,7 @@ export default function App() {
|
|||||||
>
|
>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/users" element={<Users />} />
|
<Route path="/users" element={<Users />} />
|
||||||
|
<Route path="/files" element={<Files />} />
|
||||||
<Route path="/samba" element={<Samba />} />
|
<Route path="/samba" element={<Samba />} />
|
||||||
<Route path="/nfs" element={<Nfs />} />
|
<Route path="/nfs" element={<Nfs />} />
|
||||||
<Route path="/log" element={<Log />} />
|
<Route path="/log" element={<Log />} />
|
||||||
|
|||||||
+80
-2
@@ -75,6 +75,58 @@ export interface VersionInfo {
|
|||||||
commit: string;
|
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 {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
constructor(status: number, message: string) {
|
constructor(status: number, message: string) {
|
||||||
@@ -84,10 +136,11 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
async function request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
||||||
|
const isFormData = body instanceof FormData;
|
||||||
const res = await fetch(`/api${path}`, {
|
const res = await fetch(`/api${path}`, {
|
||||||
method,
|
method,
|
||||||
headers: body ? { "Content-Type": "application/json" } : undefined,
|
headers: isFormData ? undefined : body ? { "Content-Type": "application/json" } : undefined,
|
||||||
body: body ? JSON.stringify(body) : undefined,
|
body: isFormData ? body : body ? JSON.stringify(body) : undefined,
|
||||||
});
|
});
|
||||||
if (res.status === 204) {
|
if (res.status === 204) {
|
||||||
return undefined as T;
|
return undefined as T;
|
||||||
@@ -138,6 +191,31 @@ export const api = {
|
|||||||
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
|
listWatchedMounts: () => request<{ mounts: WatchedMount[] }>("GET", "/system/watched-mounts"),
|
||||||
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
|
createWatchedMount: (path: string) => request<WatchedMount>("POST", "/system/watched-mounts", { path }),
|
||||||
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
|
deleteWatchedMount: (id: number) => request<void>("DELETE", `/system/watched-mounts/${id}`),
|
||||||
|
|
||||||
|
// files
|
||||||
|
fileCapabilities: () => request<FileCapabilities>("GET", "/files/capabilities"),
|
||||||
|
fileRoots: () => request<{ roots: string[]; unrestricted: boolean }>("GET", "/files/roots"),
|
||||||
|
listFiles: (path: string, page = 1, limit = 200) =>
|
||||||
|
request<DirList>("GET", `/files/?path=${encodeURIComponent(path)}&page=${page}&limit=${limit}`),
|
||||||
|
fileInfo: (path: string) => request<FileInfo>("GET", `/files/info?path=${encodeURIComponent(path)}`),
|
||||||
|
mkdirFile: (path: string) => request<void>("POST", "/files/mkdir", { path }),
|
||||||
|
renameFile: (path: string, newName: string) =>
|
||||||
|
request<void>("POST", "/files/rename", { path, newName }),
|
||||||
|
chmodFile: (path: string, mode: string) =>
|
||||||
|
request<void>("POST", "/files/chmod", { path, mode }),
|
||||||
|
chownFile: (path: string, uid: number, gid: number) =>
|
||||||
|
request<void>("POST", "/files/chown", { path, uid, gid }),
|
||||||
|
deleteFile: (path: string) => request<void>("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<FilePreview>("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 {
|
export function formatBytes(bytes: number): string {
|
||||||
|
|||||||
@@ -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 <span className="text-slate-500">↑</span>;
|
||||||
|
if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "gif" || ext === "webp" || ext === "svg")
|
||||||
|
return <span className="text-blue-400">🖼</span>;
|
||||||
|
if (ext === "mp4" || ext === "avi" || ext === "mkv" || ext === "mov")
|
||||||
|
return <span className="text-purple-400">🎬</span>;
|
||||||
|
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac")
|
||||||
|
return <span className="text-green-400">🎵</span>;
|
||||||
|
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z")
|
||||||
|
return <span className="text-yellow-400">📦</span>;
|
||||||
|
if (ext === "pdf")
|
||||||
|
return <span className="text-red-400">📄</span>;
|
||||||
|
if (ext === "txt" || ext === "md" || ext === "log" || ext === "cfg" || ext === "conf" || ext === "json" || ext === "yaml" || ext === "yml" || ext === "toml" || ext === "xml")
|
||||||
|
return <span className="text-emerald-400">📝</span>;
|
||||||
|
if (name.includes("/")) return <span className="text-yellow-400">📁</span>;
|
||||||
|
return <span className="text-slate-400">📄</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
initialPath?: string;
|
||||||
|
onSelect: (path: string) => void;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileBrowserModal({ initialPath, onSelect, onClose }: Props) {
|
||||||
|
const [roots, setRoots] = useState<string[]>([]);
|
||||||
|
const [currentPath, setCurrentPath] = useState(initialPath ?? "/");
|
||||||
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [capabilities, setCapabilities] = useState<FileCapabilities | null>(null);
|
||||||
|
const [showMkdir, setShowMkdir] = useState(false);
|
||||||
|
const [mkdirName, setMkdirName] = useState("");
|
||||||
|
const [renameState, setRenameState] = useState<{ path: string; name: string } | null>(null);
|
||||||
|
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(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<HTMLDivElement>) {
|
||||||
|
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 (
|
||||||
|
<Modal title="Explorar directorio" onClose={onClose} maxWidth="4xl">
|
||||||
|
<div className="space-y-3">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{roots.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="input w-auto"
|
||||||
|
value={roots.includes(currentPath) ? currentPath : roots[0]}
|
||||||
|
onChange={e => navigateTo(e.target.value)}
|
||||||
|
>
|
||||||
|
{roots.map(r => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-300">
|
||||||
|
<button className="btn-ghost px-2 py-1 text-xs" onClick={() => parentDir && navigateTo(parentDir)} disabled={!parentDir}>↑</button>
|
||||||
|
{breadcrumbs.map((b, i) => (
|
||||||
|
<span key={i} className="flex items-center">
|
||||||
|
<button className="hover:text-white" onClick={() => navigateTo(b.path)}>{b.part}</button>
|
||||||
|
{i < breadcrumbs.length - 1 && <span className="mx-1 text-slate-600">/</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-ghost text-xs" onClick={refresh}>↻</button>
|
||||||
|
<button className="btn-ghost text-xs" onClick={() => { setShowMkdir(true); setMkdirName(""); }}>📁+ Nueva</button>
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
className="hidden"
|
||||||
|
onChange={e => handleUpload(e.target.files)}
|
||||||
|
/>
|
||||||
|
<button className="btn-ghost text-xs" onClick={() => fileInputRef.current?.click()}>⬆ Subir</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary text-xs ml-auto"
|
||||||
|
disabled={!selectedPath}
|
||||||
|
onClick={() => selectedPath && onSelect(selectedPath)}
|
||||||
|
>
|
||||||
|
Seleccionar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="max-h-80 overflow-y-auto rounded border border-slate-700"
|
||||||
|
onDragOver={e => e.preventDefault()}
|
||||||
|
onDrop={handleDropZone}
|
||||||
|
>
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="sticky top-0 bg-slate-900 border-b border-slate-700">
|
||||||
|
<tr>
|
||||||
|
<th className="px-3 py-2 text-slate-400">Nombre</th>
|
||||||
|
<th className="px-3 py-2 text-slate-400">Tamaño</th>
|
||||||
|
<th className="px-3 py-2 text-slate-400">Modificado</th>
|
||||||
|
<th className="px-3 py-2 text-slate-400"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parentDir && (
|
||||||
|
<tr
|
||||||
|
className="cursor-pointer hover:bg-slate-800"
|
||||||
|
onDoubleClick={() => navigateTo(parentDir!)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-slate-400">..</td>
|
||||||
|
<td className="px-3 py-2 text-slate-500">—</td>
|
||||||
|
<td className="px-3 py-2 text-slate-500">—</td>
|
||||||
|
<td className="px-3 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{entries.map(entry => (
|
||||||
|
<tr
|
||||||
|
key={entry.path}
|
||||||
|
className={`cursor-pointer hover:bg-slate-800 ${selectedPath === entry.path ? "bg-slate-700" : ""}`}
|
||||||
|
onClick={() => setSelectedPath(entry.path)}
|
||||||
|
onDoubleClick={() => handleDoubleClick(entry)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 flex items-center gap-2">
|
||||||
|
<FileIcon name={entry.name} />
|
||||||
|
<span className="text-slate-100">{entry.name}</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 font-mono text-xs">
|
||||||
|
{entry.is_dir ? "—" : formatBytes(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-slate-400 text-xs">
|
||||||
|
{entry.mod_time > 0 ? new Date(entry.mod_time * 1000).toLocaleDateString() : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
{capabilities?.chmod && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs px-1 py-0.5"
|
||||||
|
onClick={e => { e.stopPropagation(); setRenameState({ path: entry.path, name: entry.name }); }}
|
||||||
|
>
|
||||||
|
✎
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs px-1 py-0.5 text-red-400"
|
||||||
|
onClick={e => { e.stopPropagation(); handleDelete(entry); }}
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-3 py-6 text-center text-slate-500">
|
||||||
|
Directorio vacío
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="px-3 py-4 text-center text-slate-500">
|
||||||
|
Cargando...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button className="btn-ghost w-full text-xs" onClick={() => loadDir(currentPath, page + 1)}>
|
||||||
|
Cargar más ({total - entries.length} restantes)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMkdir && (
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Nombre de carpeta"
|
||||||
|
value={mkdirName}
|
||||||
|
onChange={e => setMkdirName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleMkdir()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn-primary text-xs" onClick={handleMkdir}>Crear</button>
|
||||||
|
<button className="btn-ghost text-xs" onClick={() => setShowMkdir(false)}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renameState && (
|
||||||
|
<div className="flex gap-2 items-center border-t border-slate-700 pt-3">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={renameState.name}
|
||||||
|
onChange={e => setRenameState({ ...renameState, name: e.target.value })}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleRename()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn-primary text-xs" onClick={handleRename}>Renombrar</button>
|
||||||
|
<button className="btn-ghost text-xs" onClick={() => setRenameState(null)}>Cancelar</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import DirtyBanner from "./DirtyBanner";
|
|||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: "/", label: "Dashboard", end: true },
|
{ to: "/", label: "Dashboard", end: true },
|
||||||
{ to: "/users", label: "Usuarios" },
|
{ to: "/users", label: "Usuarios" },
|
||||||
|
{ to: "/files", label: "Archivos" },
|
||||||
{ to: "/samba", label: "SMB / Samba" },
|
{ to: "/samba", label: "SMB / Samba" },
|
||||||
{ to: "/nfs", label: "NFS" },
|
{ to: "/nfs", label: "NFS" },
|
||||||
{ to: "/log", label: "Historial" },
|
{ to: "/log", label: "Historial" },
|
||||||
|
|||||||
@@ -1,18 +1,31 @@
|
|||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
const widthMap: Record<string, string> = {
|
||||||
|
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({
|
export default function Modal({
|
||||||
title,
|
title,
|
||||||
onClose,
|
onClose,
|
||||||
children,
|
children,
|
||||||
|
maxWidth = "lg",
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
title: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
|
maxWidth?: keyof typeof widthMap;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
<div className="fixed inset-0 z-20 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
<div
|
<div
|
||||||
className="w-full max-w-lg rounded-lg border border-slate-700 bg-slate-900 p-6 shadow-xl"
|
className={`w-full ${widthMap[maxWidth]} rounded-lg border border-slate-700 bg-slate-900 p-6 shadow-xl`}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={value}
|
||||||
|
onChange={e => onChange(e.target.value)}
|
||||||
|
placeholder="/ruta/absoluta"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn-ghost whitespace-nowrap"
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
>
|
||||||
|
Explorar…
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{open && (
|
||||||
|
<FileBrowserModal
|
||||||
|
initialPath={value || "/"}
|
||||||
|
onSelect={p => { onChange(p); setOpen(false); }}
|
||||||
|
onClose={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 <span className="text-slate-500">↑</span>;
|
||||||
|
if (ext === "png" || ext === "jpg" || ext === "jpeg" || ext === "gif" || ext === "webp" || ext === "svg")
|
||||||
|
return <span className="text-blue-400">🖼</span>;
|
||||||
|
if (ext === "mp4" || ext === "avi" || ext === "mkv" || ext === "mov")
|
||||||
|
return <span className="text-purple-400">🎬</span>;
|
||||||
|
if (ext === "mp3" || ext === "wav" || ext === "ogg" || ext === "flac")
|
||||||
|
return <span className="text-green-400">🎵</span>;
|
||||||
|
if (ext === "zip" || ext === "tar" || ext === "gz" || ext === "rar" || ext === "7z")
|
||||||
|
return <span className="text-yellow-400">📦</span>;
|
||||||
|
if (ext === "pdf")
|
||||||
|
return <span className="text-red-400">📄</span>;
|
||||||
|
if (ext === "txt" || ext === "md" || ext === "log" || ext === "cfg" || ext === "conf" || ext === "json" || ext === "yaml" || ext === "yml" || ext === "toml" || ext === "xml")
|
||||||
|
return <span className="text-emerald-400">📝</span>;
|
||||||
|
return <span className="text-slate-400">📄</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
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<string[]>([]);
|
||||||
|
const [unrestricted, setUnrestricted] = useState(false);
|
||||||
|
const [currentPath, setCurrentPath] = useState("/");
|
||||||
|
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [hasMore, setHasMore] = useState(false);
|
||||||
|
const [total, setTotal] = useState(0);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [capabilities, setCapabilities] = useState<FileCapabilities | null>(null);
|
||||||
|
|
||||||
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchResults, setSearchResults] = useState<SearchHit[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
|
||||||
|
const [showMkdir, setShowMkdir] = useState(false);
|
||||||
|
const [mkdirName, setMkdirName] = useState("");
|
||||||
|
|
||||||
|
const [selectedEntry, setSelectedEntry] = useState<FileEntry | null>(null);
|
||||||
|
const [preview, setPreview] = useState<FilePreview | null>(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<HTMLInputElement>(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<HTMLDivElement>) {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
handleUpload(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e: DragEvent<HTMLDivElement>) {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-white">Archivos</h1>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<button className="btn-ghost" onClick={refresh}>↻ Actualizar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md bg-red-500/15 px-3 py-2 text-sm text-red-200">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
{roots.length > 1 && (
|
||||||
|
<select
|
||||||
|
className="input w-auto"
|
||||||
|
value={roots.includes(currentPath) ? currentPath : roots[0]}
|
||||||
|
onChange={e => navigateTo(e.target.value)}
|
||||||
|
>
|
||||||
|
{roots.map(r => (
|
||||||
|
<option key={r} value={r}>{r}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
{unrestricted && (
|
||||||
|
<span className="text-xs text-amber-400 bg-amber-500/10 px-2 py-1 rounded border border-amber-700/50">
|
||||||
|
Sin restricción — acceso a todo el FS
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-1 text-sm text-slate-300 flex-1 min-w-0">
|
||||||
|
<button
|
||||||
|
className="btn-ghost px-2 py-1 text-xs"
|
||||||
|
onClick={() => parentDir() && navigateTo(parentDir()!)}
|
||||||
|
disabled={!parentDir()}
|
||||||
|
>←</button>
|
||||||
|
{breadcrumbs.map((b, i) => (
|
||||||
|
<span key={i} className="flex items-center">
|
||||||
|
<button className="hover:text-white truncate max-w-32" onClick={() => navigateTo(b.path)}>{b.part}</button>
|
||||||
|
{i < breadcrumbs.length - 1 && <span className="mx-1 text-slate-600">/</span>}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSearchSubmit} className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Buscar archivos y carpetas…"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary" disabled={searching}>
|
||||||
|
{searching ? "Buscando…" : "🔍"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="card">
|
||||||
|
<div className="text-sm text-slate-400 mb-2">Resultados ({searchResults.length})</div>
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{searchResults.map((hit, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-2 py-1 px-2 hover:bg-slate-800 cursor-pointer rounded"
|
||||||
|
onClick={() => 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 });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FileIcon name={hit.name} />
|
||||||
|
<span className="text-slate-100 text-sm truncate">{hit.path}</span>
|
||||||
|
<span className="text-slate-500 text-xs ml-auto">{hit.is_dir ? "carpeta" : formatBytes(hit.size)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button className="btn-ghost text-xs mt-2 w-full" onClick={() => { setSearchResults([]); setSearchQuery(""); }}>
|
||||||
|
Limpiar búsqueda
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="btn-ghost text-xs" onClick={() => { setShowMkdir(true); setMkdirName(""); }}>📁 Nueva carpeta</button>
|
||||||
|
<input ref={fileInputRef} type="file" multiple className="hidden" onChange={e => handleUpload(e.target.files)} />
|
||||||
|
<button className="btn-ghost text-xs" onClick={() => fileInputRef.current?.click()} disabled={uploading}>
|
||||||
|
{uploading ? "↑ Subiendo…" : "⬆ Subir archivo"}
|
||||||
|
</button>
|
||||||
|
{capabilities?.chown && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedEntry) return;
|
||||||
|
setChownState({ entry: selectedEntry, uid: String(selectedEntry.mode_num >> 16), gid: String(selectedEntry.mode_num & 0xFFFF) });
|
||||||
|
}}
|
||||||
|
disabled={!selectedEntry}
|
||||||
|
>
|
||||||
|
👤 Propietario
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{capabilities?.chmod && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
if (!selectedEntry) return;
|
||||||
|
setChmodState({ entry: selectedEntry, mode: String((selectedEntry.mode_num & 0o777).toString(8)) });
|
||||||
|
}}
|
||||||
|
disabled={!selectedEntry}
|
||||||
|
>
|
||||||
|
🔒 Permisos
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`card overflow-x-auto p-0 relative ${isDragOver ? "ring-2 ring-brand-500" : ""}`}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragLeave={handleDragLeave}
|
||||||
|
onDrop={handleDropZone}
|
||||||
|
>
|
||||||
|
{isDragOver && (
|
||||||
|
<div className="absolute inset-0 bg-brand-500/20 flex items-center justify-center z-10 rounded-lg">
|
||||||
|
<span className="text-brand-300 font-medium">Suelta para subir</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<table className="w-full text-left text-sm">
|
||||||
|
<thead className="border-b border-slate-800 text-slate-400">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3">Nombre</th>
|
||||||
|
<th className="px-4 py-3">Tamaño</th>
|
||||||
|
<th className="px-4 py-3">Permisos</th>
|
||||||
|
<th className="px-4 py-3">Modificado</th>
|
||||||
|
<th className="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{parentDir() !== null && (
|
||||||
|
<tr
|
||||||
|
className="cursor-pointer hover:bg-slate-800"
|
||||||
|
onDoubleClick={() => navigateTo(parentDir()!)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2 text-slate-400">..</td>
|
||||||
|
<td className="px-4 py-2 text-slate-500">—</td>
|
||||||
|
<td className="px-4 py-2 text-slate-500">—</td>
|
||||||
|
<td className="px-4 py-2 text-slate-500">—</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{entries.map(entry => (
|
||||||
|
<tr
|
||||||
|
key={entry.path}
|
||||||
|
className={`cursor-pointer hover:bg-slate-800 ${selectedEntry?.path === entry.path ? "bg-slate-700" : ""}`}
|
||||||
|
onClick={() => setSelectedEntry(entry)}
|
||||||
|
onDoubleClick={() => handleDoubleClick(entry)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileIcon name={entry.name} />
|
||||||
|
<span className="text-slate-100">{entry.name}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-400 font-mono text-xs">
|
||||||
|
{entry.is_dir ? "—" : formatBytes(entry.size)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-400 font-mono text-xs">
|
||||||
|
{modeStr(entry.mode_num)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-slate-400 text-xs">
|
||||||
|
{entry.mod_time > 0 ? new Date(entry.mod_time * 1000).toLocaleString() : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs px-1 py-0.5"
|
||||||
|
onClick={e => { e.stopPropagation(); setRenameState({ entry, name: entry.name }); }}
|
||||||
|
title="Renombrar"
|
||||||
|
>✎</button>
|
||||||
|
{capabilities?.chmod && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs px-1 py-0.5"
|
||||||
|
onClick={e => { e.stopPropagation(); setChmodState({ entry, mode: String((entry.mode_num & 0o777).toString(8)) }); }}
|
||||||
|
title="Permisos"
|
||||||
|
>🔒</button>
|
||||||
|
)}
|
||||||
|
{capabilities?.chown && (
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs px-1 py-0.5"
|
||||||
|
onClick={e => { e.stopPropagation(); setChownState({ entry, uid: String(entry.mode_num >> 16), gid: String(entry.mode_num & 0xFFFF) }); }}
|
||||||
|
title="Propietario"
|
||||||
|
>👤</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className="btn-ghost text-xs px-1 py-0.5 text-red-400"
|
||||||
|
onClick={e => { e.stopPropagation(); handleDelete(entry); }}
|
||||||
|
title="Eliminar"
|
||||||
|
>✕</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{entries.length === 0 && !loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">
|
||||||
|
{searchQuery ? "Sin resultados" : "Directorio vacío"}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{loading && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="px-4 py-6 text-center text-slate-500">
|
||||||
|
Cargando…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasMore && (
|
||||||
|
<button className="btn-ghost w-full text-xs" onClick={() => loadDir(currentPath, page + 1)}>
|
||||||
|
Cargar más ({total - entries.length} restantes)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showMkdir && (
|
||||||
|
<Modal title="Nueva carpeta" onClose={() => setShowMkdir(false)} maxWidth="sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
placeholder="Nombre de carpeta"
|
||||||
|
value={mkdirName}
|
||||||
|
onChange={e => setMkdirName(e.target.value)}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleMkdir()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" onClick={handleMkdir}>Crear</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renameState && (
|
||||||
|
<Modal title="Renombrar" onClose={() => setRenameState(null)} maxWidth="sm">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
className="input flex-1"
|
||||||
|
value={renameState.name}
|
||||||
|
onChange={e => setRenameState({ ...renameState, name: e.target.value })}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleRename()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<button className="btn-primary" onClick={handleRename}>Renombrar</button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chmodState && (
|
||||||
|
<Modal title="Permisos" onClose={() => setChmodState(null)} maxWidth="sm">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<input
|
||||||
|
className="input w-24"
|
||||||
|
value={chmodState.mode}
|
||||||
|
onChange={e => setChmodState({ ...chmodState, mode: e.target.value })}
|
||||||
|
onKeyDown={e => e.key === "Enter" && handleChmod()}
|
||||||
|
autoFocus
|
||||||
|
placeholder="755"
|
||||||
|
/>
|
||||||
|
<span className="text-slate-400 text-sm">octal (ej: 755, 644)</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500 font-mono">
|
||||||
|
{(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(" ")}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button className="btn-ghost" onClick={() => setChmodState(null)}>Cancelar</button>
|
||||||
|
<button className="btn-primary" onClick={handleChmod}>Aplicar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{chownState && (
|
||||||
|
<Modal title="Cambiar propietario" onClose={() => setChownState(null)} maxWidth="sm">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="label">UID</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={chownState.uid}
|
||||||
|
onChange={e => setChownState({ ...chownState, uid: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="label">GID</label>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={chownState.gid}
|
||||||
|
onChange={e => setChownState({ ...chownState, gid: e.target.value })}
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button className="btn-ghost" onClick={() => setChownState(null)}>Cancelar</button>
|
||||||
|
<button className="btn-primary" onClick={handleChown}>Aplicar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPreview && selectedEntry && (
|
||||||
|
<Modal
|
||||||
|
title={selectedEntry.name}
|
||||||
|
onClose={() => { setShowPreview(false); setPreview(null); }}
|
||||||
|
maxWidth="3xl"
|
||||||
|
>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{previewLoading ? (
|
||||||
|
<div className="text-center text-slate-400 py-8">Cargando…</div>
|
||||||
|
) : preview?.type === "text" ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-2">{preview.mime} · {formatBytes(preview.size)}</div>
|
||||||
|
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 overflow-auto max-h-96 font-mono whitespace-pre-wrap break-all">
|
||||||
|
{preview.content}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
) : preview?.type === "image" ? (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-slate-500 mb-2">{preview.mime} · {formatBytes(preview.size)}</div>
|
||||||
|
<img
|
||||||
|
src={api.fileDownloadUrl(selectedEntry.path)}
|
||||||
|
alt={selectedEntry.name}
|
||||||
|
className="max-h-96 mx-auto rounded border border-slate-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="text-slate-400 mb-2">Archivo binario</div>
|
||||||
|
<div className="text-xs text-slate-500 mb-4">{preview?.mime} · {formatBytes(preview?.size ?? 0)}</div>
|
||||||
|
<a
|
||||||
|
href={api.fileDownloadUrl(selectedEntry.path)}
|
||||||
|
download={selectedEntry.name}
|
||||||
|
className="btn-primary"
|
||||||
|
>
|
||||||
|
⬇ Descargar
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!previewLoading && (
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<a
|
||||||
|
href={api.fileDownloadUrl(selectedEntry.path)}
|
||||||
|
download={selectedEntry.name}
|
||||||
|
className="btn-ghost text-xs"
|
||||||
|
>
|
||||||
|
⬇ Descargar
|
||||||
|
</a>
|
||||||
|
<button className="btn-primary text-xs" onClick={() => { setShowPreview(false); setPreview(null); }}>
|
||||||
|
Cerrar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
|
|||||||
import { api, NFSExport } from "../api";
|
import { api, NFSExport } from "../api";
|
||||||
import { useDirty } from "../DirtyContext";
|
import { useDirty } from "../DirtyContext";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
|
import PathField from "../components/PathField";
|
||||||
|
|
||||||
const empty: Partial<NFSExport> = {
|
const empty: Partial<NFSExport> = {
|
||||||
path: "",
|
path: "",
|
||||||
@@ -160,10 +161,9 @@ export default function Nfs() {
|
|||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Path (absoluto)</label>
|
<label className="label">Path (absoluto)</label>
|
||||||
<input
|
<PathField
|
||||||
className="input"
|
|
||||||
value={editing.path ?? ""}
|
value={editing.path ?? ""}
|
||||||
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
|
onChange={p => setEditing({ ...editing, path: p })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { FormEvent, useEffect, useState } from "react";
|
|||||||
import { api, SambaShare } from "../api";
|
import { api, SambaShare } from "../api";
|
||||||
import { useDirty } from "../DirtyContext";
|
import { useDirty } from "../DirtyContext";
|
||||||
import Modal from "../components/Modal";
|
import Modal from "../components/Modal";
|
||||||
|
import PathField from "../components/PathField";
|
||||||
|
|
||||||
const empty: Partial<SambaShare> = {
|
const empty: Partial<SambaShare> = {
|
||||||
name: "",
|
name: "",
|
||||||
@@ -119,10 +120,9 @@ export default function Samba() {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="label">Path (absoluto)</label>
|
<label className="label">Path (absoluto)</label>
|
||||||
<input
|
<PathField
|
||||||
className="input"
|
|
||||||
value={editing.path ?? ""}
|
value={editing.path ?? ""}
|
||||||
onChange={(e) => setEditing({ ...editing, path: e.target.value })}
|
onChange={p => setEditing({ ...editing, path: p })}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user