package api import ( "archive/zip" "encoding/json" "fmt" "io" "mime/multipart" "net" "net/http" "os" "path" "path/filepath" "strings" "sync" "time" "chfs-ng/internal/auth" "chfs-ng/internal/opslog" "chfs-ng/internal/storage" "github.com/emersion/go-webdav" ) type Server struct { Store storage.Storage Auth *auth.Manager Sessions map[string]*auth.Account SessionExpiry map[string]time.Time SessionMu sync.RWMutex SessionTimeout int WebDAVRoot string Allow string MultiRoot bool RemoveMode int Log *opslog.Logger } type fileInfo struct { Name string `json:"name"` Path string `json:"path"` Size int64 `json:"size"` Modified string `json:"modified"` Dir bool `json:"dir"` Mask string `json:"mask"` GuestMask string `json:"guestMask"` HasSubDir bool `json:"hasSubDir"` } 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) mux.HandleFunc("/chfs/rmfiles", s.remove) mux.HandleFunc("/chfs/exist", s.exist) mux.HandleFunc("/chfs/session", s.session) mux.HandleFunc("/chfs/newtext", s.newtext) mux.HandleFunc("/chfs/updatetext", s.updatetext) mux.HandleFunc("/chfs/upload", 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 s.ipFilter(mux) } func account(r *http.Request, s *Server) *auth.Account { if c, e := r.Cookie("chfs_token"); e == nil { s.SessionMu.RLock() a := s.Sessions[c.Value] exp := s.SessionExpiry[c.Value] s.SessionMu.RUnlock() if a != nil && (exp.IsZero() || time.Now().Before(exp)) { return a } if a != nil { s.SessionMu.Lock() delete(s.Sessions, c.Value) delete(s.SessionExpiry, c.Value) s.SessionMu.Unlock() } } return s.Auth.Guest } func (s *Server) logOp(r *http.Request, action string) { if s.Log == nil { return } a := account(r, s) user := "" if a != nil { user = a.Username } _ = s.Log.Write(clientIPString(r), user, action) } func clientIPString(r *http.Request) string { ip := clientIP(r) if ip == nil { return "-" } return ip.String() } func writeJSON(w http.ResponseWriter, v any) { w.Header().Set("Content-Type", "application/json; charset=utf-8") json.NewEncoder(w).Encode(v) } func qpath(r *http.Request) string { return strings.Trim(strings.ReplaceAll(r.URL.Query().Get("filepath"), "\\", "/"), "/") } func permString(p auth.PermMask) string { if p.HasDelete() { return "RWD" } if p.HasWrite() { return "RW" } if p.HasRead() { return "R" } return "" } func (s *Server) files(w http.ResponseWriter, r *http.Request) { a := account(r, s) p := qpath(r) m := a.MaskFor(p) if !m.HasRead() { http.Error(w, "forbidden", 403) return } list, e := s.Store.List(p) if e != nil { http.Error(w, e.Error(), 404) return } out := make([]fileInfo, 0, len(list)) for _, fi := range list { fp := path.Join(p, fi.Name()) sub := false if fi.IsDir() { if xs, e := s.Store.List(fp); e == nil { sub = len(xs) > 0 } } out = append(out, fileInfo{Name: fi.Name(), Path: fp, Size: fi.Size(), Modified: fi.ModTime().Format(time.RFC3339), Dir: fi.IsDir(), Mask: permString(a.MaskFor(fp)), GuestMask: permString(s.Auth.Guest.MaskFor(fp)), HasSubDir: sub}) } virtual := p == "" && s.MultiRoot writeJSON(w, map[string]any{"files": out, "currentMask": permString(m), "virtualRoot": virtual}) } 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 } if p == "" { p = strings.Trim(strings.ReplaceAll(r.FormValue("filepath"), "\\", "/"), "/") } 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(p); e != nil { http.Error(w, e.Error(), 500) return } s.logOp(r, fmt.Sprintf("created directory:'%s'", p)) writeJSON(w, map[string]any{"ok": true}) } func (s *Server) rename(w http.ResponseWriter, r *http.Request) { a := account(r, s) old := strings.Trim(r.FormValue("old"), "/") neu := strings.Trim(r.FormValue("new"), "/") if !a.MaskFor(old).HasWrite() { http.Error(w, "forbidden", 403) return } if e := s.Store.Rename(old, neu); e != nil { http.Error(w, e.Error(), 500) return } s.logOp(r, fmt.Sprintf("renamed '%s' to '%s'", old, neu)) writeJSON(w, map[string]any{"ok": true}) } func (s *Server) remove(w http.ResponseWriter, r *http.Request) { a := account(r, s) _ = r.ParseMultipartForm(16 << 20) _ = r.ParseForm() items := r.Form["filepath"] if len(items) == 0 { items = r.Form["path"] } for _, p := range items { if !a.MaskFor(p).HasDelete() { http.Error(w, "forbidden", 403) return } if e := s.removePath(p); e != nil { http.Error(w, e.Error(), 500) return } s.logOp(r, fmt.Sprintf("removed '%s'", strings.Trim(p, "/"))) } writeJSON(w, map[string]any{"ok": true}) } func (s *Server) removePath(p string) error { mode := s.RemoveMode if mode == 0 { mode = 1 } t, ok := s.Store.(storage.Trashable) switch mode { case 1: if !ok { return fmt.Errorf("storage does not support system trash") } return t.Trash(p) case 2: home, err := os.UserHomeDir() if err != nil { return err } return t.MoveToTrash(p, filepath.Join(home, ".chfs_trashbin")) case 3: return s.Store.Remove(p) default: return fmt.Errorf("invalid file.remove mode: %d", mode) } } func (s *Server) exist(w http.ResponseWriter, r *http.Request) { p := strings.Trim(strings.ReplaceAll(r.URL.Query().Get("file"), "\\", "/"), "/") _, e := s.Store.Stat(p) writeJSON(w, map[string]any{"exist": e == nil}) } func (s *Server) session(w http.ResponseWriter, r *http.Request) { if r.Method == "DELETE" { if c, e := r.Cookie("chfs_token"); e == nil { s.SessionMu.Lock() delete(s.Sessions, c.Value) delete(s.SessionExpiry, c.Value) s.SessionMu.Unlock() } http.SetCookie(w, &http.Cookie{Name: "chfs_token", Value: "", Path: "/", MaxAge: -1, HttpOnly: true}) writeJSON(w, map[string]any{"ok": true}) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } user, pwd := r.FormValue("user"), r.FormValue("pwd") a := s.Auth.Authenticate(user, pwd) if a == nil { http.Error(w, "invalid account", http.StatusUnauthorized) return } token := auth.NewToken() exp := time.Time{} maxAge := 0 if s.SessionTimeout > 0 { exp = time.Now().Add(time.Duration(s.SessionTimeout) * time.Minute) maxAge = s.SessionTimeout * 60 } s.SessionMu.Lock() if s.Sessions == nil { s.Sessions = make(map[string]*auth.Account) } if s.SessionExpiry == nil { s.SessionExpiry = make(map[string]time.Time) } s.Sessions[token] = a s.SessionExpiry[token] = exp s.SessionMu.Unlock() http.SetCookie(w, &http.Cookie{Name: "chfs_token", Value: token, Path: "/", MaxAge: maxAge, HttpOnly: true, SameSite: http.SameSiteLaxMode}) writeJSON(w, map[string]any{"ok": true, "user": a.Username}) } func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { a := account(r, s) 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 } f, e := s.Store.Create(p) if e != nil { http.Error(w, e.Error(), 500) return } defer f.Close() io.WriteString(f, r.FormValue("text")) s.logOp(r, fmt.Sprintf("create a text file:'%s'", p)) writeJSON(w, map[string]any{"ok": true}) } 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 } s.logOp(r, fmt.Sprintf("update text file:'%s'", p)) 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 } var saved []string for _, hs := range r.MultipartForm.File { for _, h := range hs { 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 } saved = append(saved, h.Filename) } } for _, name := range saved { s.logOp(r, fmt.Sprintf("upload '%s'", path.Join(dir, name))) } w.WriteHeader(http.StatusCreated) writeJSON(w, map[string]any{"ok": true, "files": saved}) } 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(target) if e != nil { return e } defer f.Close() _, 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())) } s.logOp(r, fmt.Sprintf("download '%s'", p)) 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 } s.logOp(r, fmt.Sprintf("download directory '%s'", p)) 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) } func (s *Server) ipFilter(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if s.Allow != "" && !allowedIP(s.Allow, clientIP(r)) { http.Error(w, "forbidden", http.StatusForbidden) return } next.ServeHTTP(w, r) }) } func clientIP(r *http.Request) net.IP { host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { return net.ParseIP(host) } return net.ParseIP(r.RemoteAddr) } func allowedIP(rule string, ip net.IP) bool { if ip == nil { return false } rule = strings.TrimSpace(rule) black := false if strings.HasPrefix(strings.ToLower(rule), "not(") && strings.HasSuffix(rule, ")") { black = true rule = strings.TrimSpace(rule[4 : len(rule)-1]) } matched := false for _, item := range strings.Split(rule, ",") { item = strings.TrimSpace(item) if item == "" { continue } if ipInItem(ip, item) { matched = true break } } if black { return !matched } return matched } func ipInItem(ip net.IP, item string) bool { if strings.Contains(item, "-") { parts := strings.SplitN(item, "-", 2) lo, hi := net.ParseIP(strings.TrimSpace(parts[0])), net.ParseIP(strings.TrimSpace(parts[1])) if lo == nil || hi == nil { return false } v := ip.To4() l := lo.To4() h := hi.To4() if v == nil || l == nil || h == nil { return false } return bytesCompare(v, l) >= 0 && bytesCompare(v, h) <= 0 } return ip.Equal(net.ParseIP(item)) } func bytesCompare(a, b net.IP) int { for i := range a { if a[i] < b[i] { return -1 } if a[i] > b[i] { return 1 } } return 0 }