50ed8abe95
- 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
306 lines
7.0 KiB
Go
306 lines
7.0 KiB
Go
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]
|
|
}
|
|
}
|
|
}
|
|
}
|