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:
@@ -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"
|
||||
}
|
||||
}
|
||||
+28
-16
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user