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:
2026-07-06 01:56:40 -04:00
parent 0da789cd96
commit 50ed8abe95
17 changed files with 2209 additions and 30 deletions
+28 -16
View File
@@ -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,
}
}
+313
View File
@@ -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
}
+16
View File
@@ -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)
})
})
})