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 }