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:
+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