diff --git a/chfs-ng/cmd/chfs/main.go b/chfs-ng/cmd/chfs/main.go index 59d6614..c6092c2 100644 --- a/chfs-ng/cmd/chfs/main.go +++ b/chfs-ng/cmd/chfs/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "path/filepath" + "strings" "chfs-ng/internal/api" "chfs-ng/internal/auth" @@ -17,37 +18,65 @@ func main() { file := flag.String("file", "", "configuration file") pathFlag := flag.String("path", "/data", "shared path") port := flag.Int("port", 80, "listen port") + allow := flag.String("allow", "", "allowed IPv4 addresses") + var rules multiFlag + flag.Var(&rules, "rule", "access rule (repeatable)") flag.Parse() + cfg := config.Default() if *file != "" { - var err error - cfg, err = config.Load(*file) + loaded, err := config.Load(*file) if err != nil && !os.IsNotExist(err) { log.Fatal(err) } - } - if *pathFlag != "/data" || *file == "" { - if *file == "" { - cfg.Path = []string{*pathFlag} - cfg.Port = *port + if err == nil { + cfg = loaded } } + flag.CommandLine.Visit(func(f *flag.Flag) { + switch f.Name { + case "path": + cfg.Path = splitPath(*pathFlag) + case "port": + cfg.Port = *port + case "allow": + cfg.Allow = *allow + case "rule": + cfg.Rules = append([]string(nil), rules...) + } + }) + if len(cfg.Path) == 0 { + cfg.Path = []string{"/data"} + } + if err := cfg.Validate(); err != nil { + log.Fatal(err) + } + root := cfg.Path[0] - if e := os.MkdirAll(root, 0755); e != nil { - log.Fatal(e) + if len(cfg.Path) > 1 { + log.Printf("warning: %d shared roots configured; v1 compatibility currently serves the first root %q", len(cfg.Path), root) } - mgr, _ := auth.ParseRules(cfg.Rules) - srv := &api.Server{Store: storage.Local{Root: root}, Auth: mgr, Sessions: map[string]*auth.Account{}} + if err := os.MkdirAll(root, 0755); err != nil { + log.Fatal(err) + } + mgr, err := auth.ParseRules(cfg.Rules) + if err != nil { + log.Fatal(err) + } + srv := &api.Server{Store: storage.Local{Root: root}, Auth: mgr, Sessions: map[string]*auth.Account{}, WebDAVRoot: root} addr := ":" + itoa(cfg.Port) - if cfg.Port == 0 { - addr = ":" + itoa(*port) - } log.Printf("chfs-ng listening on %s root=%s", addr, filepath.Clean(root)) if cfg.SSLCert != "" && cfg.SSLKey != "" { log.Fatal(http.ListenAndServeTLS(addr, cfg.SSLCert, cfg.SSLKey, srv.Handler())) } log.Fatal(http.ListenAndServe(addr, srv.Handler())) } + +type multiFlag []string + +func (m *multiFlag) String() string { return strings.Join(*m, "|") } +func (m *multiFlag) Set(v string) error { *m = append(*m, v); return nil } + func itoa(n int) string { const d = "0123456789" if n == 0 { @@ -60,3 +89,14 @@ func itoa(n int) string { } return string(b) } + +func splitPath(v string) []string { + var out []string + for _, p := range strings.Split(v, "|") { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/chfs-ng/go.mod b/chfs-ng/go.mod index 66ce25b..dc99991 100644 --- a/chfs-ng/go.mod +++ b/chfs-ng/go.mod @@ -1,3 +1,5 @@ module chfs-ng go 1.22 + +require github.com/emersion/go-webdav v0.7.0 diff --git a/chfs-ng/go.sum b/chfs-ng/go.sum new file mode 100644 index 0000000..bec93c6 --- /dev/null +++ b/chfs-ng/go.sum @@ -0,0 +1,5 @@ +github.com/emersion/go-ical v0.0.0-20240127095438-fc1c9d8fb2b6/go.mod h1:BEksegNspIkjCQfmzWgsgbu6KdeJ/4LwUZs7DMBzjzw= +github.com/emersion/go-vcard v0.0.0-20230815062825-8fda7d206ec9/go.mod h1:HMJKR5wlh/ziNp+sHEDV2ltblO4JD2+IdDOWtGcQBTM= +github.com/emersion/go-webdav v0.7.0 h1:cp6aBWXBf8Sjzguka9VJarr4XTkGc2IHxXI1Gq3TKpA= +github.com/emersion/go-webdav v0.7.0/go.mod h1:mI8iBx3RAODwX7PJJ7qzsKAKs/vY429YfS2/9wKnDbQ= +github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 4b384a3..4f1bf73 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -1,22 +1,27 @@ package api import ( + "archive/zip" "encoding/json" + "fmt" "io" "mime/multipart" "net/http" "path" + "path/filepath" "strings" "time" "chfs-ng/internal/auth" "chfs-ng/internal/storage" + "github.com/emersion/go-webdav" ) type Server struct { - Store storage.Storage - Auth *auth.Manager - Sessions map[string]*auth.Account + Store storage.Storage + Auth *auth.Manager + Sessions map[string]*auth.Account + WebDAVRoot string } type fileInfo struct { Name string `json:"name"` @@ -31,6 +36,8 @@ type fileInfo struct { func (s *Server) Handler() http.Handler { mux := http.NewServeMux() + mux.HandleFunc("/webdav/", s.webdav) + mux.HandleFunc("/webdav", s.webdav) mux.HandleFunc("/chfs/files", s.files) mux.HandleFunc("/chfs/newdir", s.newdir) mux.HandleFunc("/chfs/rename", s.rename) @@ -40,7 +47,12 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/chfs/newtext", s.newtext) mux.HandleFunc("/chfs/updatetext", s.updatetext) mux.HandleFunc("/chfs/upload", s.upload) - mux.HandleFunc("/chfs/upload2", s.upload) + mux.HandleFunc("/chfs/upload2", s.upload2) + mux.HandleFunc("/chfs/search", s.search) + mux.HandleFunc("/chfs/downloaddir/", s.downloadDir) + mux.HandleFunc("/chfs/shared", s.shared) + mux.HandleFunc("/chfs/preview", s.preview) + mux.HandleFunc("/", s.fileHandler) return mux } func account(r *http.Request, s *Server) *auth.Account { @@ -97,21 +109,28 @@ func (s *Server) files(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"files": out, "currentMask": permString(m), "virtualRoot": false}) } func (s *Server) newdir(w http.ResponseWriter, r *http.Request) { + _ = r.ParseMultipartForm(16 << 20) + _ = r.ParseForm() a := account(r, s) p := qpath(r) if !a.MaskFor(p).HasWrite() { http.Error(w, "forbidden", 403) return } - name := r.FormValue("name") - if name == "" { - name = r.FormValue("new") + if p == "" { + p = strings.Trim(strings.ReplaceAll(r.FormValue("filepath"), "\\", "/"), "/") } - if name == "" { - http.Error(w, "name required", 400) + if p == "" { + p = strings.Trim(r.FormValue("name"), "/") + } + if p == "" { + p = strings.Trim(r.FormValue("new"), "/") + } + if p == "" { + http.Error(w, "filepath required", 400) return } - if e := s.Store.MkdirAll(path.Join(p, name)); e != nil { + if e := s.Store.MkdirAll(p); e != nil { http.Error(w, e.Error(), 500) return } @@ -162,8 +181,13 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) { return } if r.Method == "GET" { - a := account(r, s) - writeJSON(w, map[string]any{"login": a != nil, "user": a.Username}) + user := "" + if c, e := r.Cookie("chfs_token"); e == nil { + if x := s.Sessions[c.Value]; x != nil { + user = x.Username + } + } + writeJSON(w, map[string]any{"login": user != "", "user": user}) return } user, pwd := r.FormValue("user"), r.FormValue("pwd") @@ -179,10 +203,16 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) { } func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { a := account(r, s) - p := strings.Trim(r.FormValue("filepath"), "/") - if filename := strings.Trim(r.FormValue("filename"), "/"); filename != "" { - p = path.Join(p, filename) + dir := strings.Trim(strings.ReplaceAll(r.FormValue("filepath"), "\\", "/"), "/") + filename := strings.Trim(r.FormValue("title"), "/") + if filename == "" { + filename = strings.Trim(r.FormValue("filename"), "/") } + if filename == "" { + http.Error(w, "title required", 400) + return + } + p := path.Join(dir, filename) if !a.MaskFor(p).HasWrite() { http.Error(w, "forbidden", 403) return @@ -196,22 +226,47 @@ func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { io.WriteString(f, r.FormValue("text")) writeJSON(w, map[string]any{"ok": true}) } -func (s *Server) updatetext(w http.ResponseWriter, r *http.Request) { s.newtext(w, r) } +func (s *Server) updatetext(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + dir := strings.Trim(r.FormValue("filepath"), "/") + name := strings.Trim(r.FormValue("filename"), "/") + p := path.Join(dir, name) + if name == "" || !a.MaskFor(p).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + f, e := s.Store.Create(p) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + defer f.Close() + if _, e = io.WriteString(f, r.FormValue("text")); e != nil { + http.Error(w, e.Error(), 500) + return + } + writeJSON(w, map[string]any{"ok": true}) +} func (s *Server) upload(w http.ResponseWriter, r *http.Request) { + if e := r.ParseMultipartForm(64 << 20); e != nil { + http.Error(w, e.Error(), 400) + return + } a := account(r, s) dir := strings.Trim(r.FormValue("folder"), "/") if !a.MaskFor(dir).HasWrite() { http.Error(w, "forbidden", 403) return } - if e := r.ParseMultipartForm(64 << 20); e != nil { - http.Error(w, e.Error(), 400) - return - } var saved []string for _, hs := range r.MultipartForm.File { for _, h := range hs { - if e := savePart(s.Store, dir, h); e != nil { + name := filepath.ToSlash(h.Filename) + if name == "" || strings.HasPrefix(name, "/") || strings.Contains(name, "../") || name == ".." { + http.Error(w, "invalid filename", 400) + return + } + if e := savePart(s.Store, path.Join(dir, name), h); e != nil { http.Error(w, e.Error(), 500) return } @@ -221,13 +276,13 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusCreated) writeJSON(w, map[string]any{"ok": true, "files": saved}) } -func savePart(st storage.Storage, dir string, h *multipart.FileHeader) error { +func savePart(st storage.Storage, target string, h *multipart.FileHeader) error { in, e := h.Open() if e != nil { return e } defer in.Close() - f, e := st.Create(path.Join(dir, h.Filename)) + f, e := st.Create(target) if e != nil { return e } @@ -235,3 +290,222 @@ func savePart(st storage.Storage, dir string, h *multipart.FileHeader) error { _, e = io.Copy(f, in) return e } + +func (s *Server) upload2(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + dir := strings.Trim(r.URL.Query().Get("folder"), "/") + if !a.MaskFor(dir).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + if err := r.ParseMultipartForm(128 << 20); err != nil { + http.Error(w, err.Error(), 400) + return + } + var saved []string + for _, hs := range r.MultipartForm.File { + for _, h := range hs { + name := filepath.ToSlash(h.Filename) + name = strings.TrimPrefix(name, "/") + if name == "" || strings.Contains(name, "../") || name == ".." { + http.Error(w, "invalid filename", 400) + return + } + if err := savePart(s.Store, path.Join(dir, name), h); err != nil { + http.Error(w, err.Error(), 500) + return + } + saved = append(saved, path.Join(dir, name)) + } + } + w.WriteHeader(http.StatusCreated) + writeJSON(w, map[string]any{"ok": true, "files": saved}) +} + +func (s *Server) search(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + if !a.MaskFor("").HasRead() { + http.Error(w, "forbidden", 403) + return + } + needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str"))) + if needle == "" { + writeJSON(w, map[string]any{"files": []fileInfo{}}) + return + } + var out []fileInfo + var walk func(string) error + walk = func(dir string) error { + items, err := s.Store.List(dir) + if err != nil { + return err + } + for _, fi := range items { + p := path.Join(dir, fi.Name()) + if !a.MaskFor(p).HasRead() { + continue + } + if strings.Contains(strings.ToLower(fi.Name()), needle) { + out = append(out, fileInfo{Name: fi.Name(), Path: p, Size: fi.Size(), Modified: fi.ModTime().Format(time.RFC3339), Dir: fi.IsDir(), Mask: permString(a.MaskFor(p)), GuestMask: permString(s.Auth.Guest.MaskFor(p))}) + } + if fi.IsDir() { + if err := walk(p); err != nil { + return err + } + } + } + return nil + } + if err := walk(""); err != nil { + http.Error(w, err.Error(), 500) + return + } + writeJSON(w, map[string]any{"files": out}) +} +func (s *Server) shared(w http.ResponseWriter, r *http.Request) { + file := strings.Trim(r.URL.Query().Get("file"), "/") + if file == "" { + file = strings.TrimPrefix(r.URL.Path, "/chfs/shared/") + } + s.serveFile(w, r, file, true) +} + +func (s *Server) preview(w http.ResponseWriter, r *http.Request) { + if !account(r, s).MaskFor(strings.Trim(r.URL.Query().Get("file"), "/")).HasRead() { + http.Error(w, "forbidden", 403) + return + } + file := strings.Trim(r.URL.Query().Get("file"), "/") + if file == "" { + http.Error(w, "file required", 400) + return + } + s.serveFile(w, r, file, false) +} + +func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" || strings.HasPrefix(r.URL.Path, "/asset/") { + http.NotFound(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/chfs/") || r.URL.Path == "/webdav" { + http.NotFound(w, r) + return + } + p := strings.Trim(strings.ReplaceAll(r.URL.Path, "\\", "/"), "/") + if p == "" { + http.NotFound(w, r) + return + } + s.serveFile(w, r, p, false) +} + +func (s *Server) serveFile(w http.ResponseWriter, r *http.Request, p string, attachment bool) { + a := account(r, s) + p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/") + if !a.MaskFor(p).HasRead() { + http.Error(w, "forbidden", 403) + return + } + fi, err := s.Store.Stat(p) + if err != nil { + http.NotFound(w, r) + return + } + if fi.IsDir() { + http.Error(w, "directory", 400) + return + } + f, err := s.Store.Open(p) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer f.Close() + if attachment { + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fi.Name())) + } + http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) +} + +func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + p := strings.TrimPrefix(r.URL.Path, "/chfs/downloaddir/") + p = strings.Trim(p, "/") + if !a.MaskFor(p).HasRead() { + http.Error(w, "forbidden", 403) + return + } + fi, err := s.Store.Stat(p) + if err != nil || !fi.IsDir() { + http.NotFound(w, r) + return + } + name := fi.Name() + if name == "." || name == "" { + name = "download" + } + w.Header().Set("Content-Type", "application/zip") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q.zip", name)) + zw := zip.NewWriter(w) + defer zw.Close() + var walk func(string, string) error + walk = func(dir, prefix string) error { + items, err := s.Store.List(dir) + if err != nil { + return err + } + for _, item := range items { + p2 := path.Join(dir, item.Name()) + rel := path.Join(prefix, item.Name()) + if !a.MaskFor(p2).HasRead() { + continue + } + if item.IsDir() { + if err := walk(p2, rel); err != nil { + return err + } + continue + } + h, err := zw.Create(rel) + if err != nil { + return err + } + f, err := s.Store.Open(p2) + if err != nil { + return err + } + _, err = io.Copy(h, f) + f.Close() + if err != nil { + return err + } + } + return nil + } + if err := walk(p, ""); err != nil { + return + } +} + +func (s *Server) webdav(w http.ResponseWriter, r *http.Request) { + if s.WebDAVRoot == "" { + http.NotFound(w, r) + return + } + vp := strings.TrimPrefix(r.URL.Path, "/webdav") + vp = strings.Trim(vp, "/") + if !account(r, s).MaskFor(vp).HasRead() { + http.Error(w, "forbidden", 403) + return + } + if r.Method != http.MethodGet && r.Method != http.MethodHead && !account(r, s).MaskFor(vp).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + old := r.URL.Path + r.URL.Path = "/" + vp + defer func() { r.URL.Path = old }() + h := &webdav.Handler{FileSystem: webdav.LocalFileSystem(s.WebDAVRoot)} + h.ServeHTTP(w, r) +}