package api import ( "archive/zip" "bytes" "context" _ "embed" "encoding/json" "fmt" "github.com/skip2/go-qrcode" "html" "image" _ "image/gif" _ "image/jpeg" "image/png" "io" "mime/multipart" "net" "net/http" "net/url" "os" "path" "path/filepath" "strings" "sync" "time" "chfs-ng/internal/auth" "chfs-ng/internal/opslog" "chfs-ng/internal/storage" "github.com/anthonynsimon/bild/transform" "github.com/emersion/go-webdav" ) //go:embed web/index.html var webIndex []byte 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 WebDAVFS webdav.FileSystem Allow string MultiRoot bool RemoveMode int ImagePreview bool FolderDownload string HTMLTitle string HTMLNotice string Log *opslog.Logger UserStore *auth.UserStore UserHome string } type fileInfo struct { Name string `json:"name"` Size int64 `json:"size"` Modified string `json:"modified"` Dir bool `json:"dir"` Mask string `json:"mask"` GuestMask string `json:"guestMask"` HasSubDir bool `json:"hasSubDir"` Icon string `json:"icon"` } func (s *Server) Handler() http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", s.health) 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/move", s.move) mux.HandleFunc("/chfs/copy", s.copy) mux.HandleFunc("/chfs/rmfiles", s.remove) mux.HandleFunc("/chfs/exist", s.exist) mux.HandleFunc("/chfs/session", s.session) mux.HandleFunc("/chfs/me", s.me) mux.HandleFunc("/chfs/roots", s.roots) mux.HandleFunc("/chfs/users", s.users) mux.HandleFunc("/chfs/password", s.password) 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/downloadzip", s.downloadSelectedZip) mux.HandleFunc("/chfs/qrcode", s.qrcode) mux.HandleFunc("/chfs/shared", s.shared) mux.HandleFunc("/chfs/shared/", s.shared) mux.HandleFunc("/chfs/preview", s.preview) mux.HandleFunc("/asset/api.html", s.apiDoc) mux.HandleFunc("/", s.fileHandler) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Go's ServeMux canonicalizes a subtree route before dispatching. // CHFS v4 deliberately returns a relative 301 from /chfs/shared, so // intercept the exact path before ServeMux gets a chance to rewrite it. if r.URL.Path == "/chfs/shared" { s.shared(w, r) return } mux.ServeHTTP(w, r) }) return s.ipFilter(h) } func account(r *http.Request, s *Server) *auth.Account { c, err := r.Cookie("JWT") if err != nil { return s.Auth.Guest } username, err := auth.GetAccFromToken(c.Value) if err != nil { return s.Auth.Guest } if a, ok := s.Auth.Accounts[username]; ok && (a.Enabled || a.PasswordHash == "") { return a } 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") json.NewEncoder(w).Encode(v) } func writeJSONError(w http.ResponseWriter, status int, message string) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(map[string]string{"error": message}) } func unsafePath(p string) bool { // Reject traversal and NUL before any permission/storage resolution. // URL.Query/FormValue has already performed the single HTTP decode, so // double-encoded traversal remains a literal filename rather than being // decoded again by this layer. if strings.IndexByte(p, 0) >= 0 { return true } p = strings.ReplaceAll(p, "\\", "/") for _, part := range strings.Split(p, "/") { if part == ".." { return true } } return false } 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 iconFor(fi os.FileInfo) string { if fi.IsDir() { return "asset/icons/folder.svg" } ext := strings.ToLower(filepath.Ext(fi.Name())) switch ext { case ".txt", ".md", ".log", ".json", ".xml", ".yaml", ".yml", ".ini", ".conf", ".cfg": return "asset/icons/txt.svg" case ".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".svg": return "asset/icons/image.svg" case ".mp3", ".wav", ".flac", ".aac", ".m4a", ".ogg": return "asset/icons/audio.svg" case ".mp4", ".mkv", ".avi", ".mov", ".webm": return "asset/icons/video.svg" case ".zip", ".rar", ".7z", ".tar", ".gz": return "asset/icons/archive.svg" case ".pdf": return "asset/icons/pdf.svg" default: return "asset/icons/unknown.svg" } } func originalTime(t time.Time) string { return t.Format("2006-01-02 15:04:05") } func writeCreated(w http.ResponseWriter) { w.WriteHeader(http.StatusCreated) } func (s *Server) files(w http.ResponseWriter, r *http.Request) { a := account(r, s) if unsafePath(r.URL.Query().Get("filepath")) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } p := qpath(r) m := a.MaskFor(p) if !m.HasRead() { // CHFS keeps the files endpoint HTTP-successful for a denied directory // and returns an empty listing with currentMask="". writeJSON(w, map[string]any{"files": []fileInfo{}, "currentMask": "", "virtualRoot": 0}) return } list, e := s.Store.List(p) if e != nil { // CHFS treats a non-directory / nonexistent filepath as a malformed // files request rather than exposing the filesystem error. writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } out := make([]fileInfo, 0, len(list)) for _, fi := range list { fp := path.Join(p, fi.Name()) // A rule.none entry is not merely read-less metadata: CHFS hides the // entry from directory listings because the directory itself is denied. if !a.MaskForEntry(fp).HasRead() { continue } sub := false if fi.IsDir() { if xs, e := s.Store.List(fp); e == nil { for _, child := range xs { if child.IsDir() { sub = true break } } } } out = append(out, fileInfo{Name: fi.Name(), Size: fi.Size(), Modified: originalTime(fi.ModTime()), Dir: fi.IsDir(), Mask: permString(a.MaskForEntry(fp)), GuestMask: permString(s.Auth.Guest.MaskForEntry(fp)), HasSubDir: sub, Icon: iconFor(fi)}) } virtual := 0 if p == "" && s.MultiRoot { virtual = 1 } 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() p := qpath(r) if unsafePath(r.URL.Query().Get("filepath")) || unsafePath(r.FormValue("filepath")) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") 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 } a := account(r, s) if !a.MaskFor(p).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") 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)) writeCreated(w) } func checkDeleteTree(store storage.Storage, a *auth.Account, root string) error { info, err := store.Stat(root) if err != nil { return err } if !a.MaskFor(root).HasDelete() { return fmt.Errorf("delete permission denied") } if !info.IsDir() { return nil } items, err := store.List(root) if err != nil { return err } for _, item := range items { p := path.Join(root, item.Name()) if err := checkDeleteTree(store, a, p); err != nil { return err } } return nil } func (s *Server) rename(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawOld, rawNew := r.FormValue("old"), r.FormValue("new") if unsafePath(rawOld) || unsafePath(rawNew) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } old := strings.Trim(rawOld, "/") neu := strings.Trim(rawNew, "/") if old == "" || neu == "" || !a.MaskFor(old).HasWrite() || !a.MaskFor(path.Dir(neu)).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } if old != neu { if _, e := s.Store.Stat(neu); e == nil { // CHFS rename is create-only: an existing destination is rejected // rather than overwritten, even when the caller has delete access. writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } else if !os.IsNotExist(e) { http.Error(w, e.Error(), 500) 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)) writeCreated(w) } func (s *Server) transfer(w http.ResponseWriter, r *http.Request, copyOnly bool) { a := account(r, s) rawSrc, rawDst := r.FormValue("source"), r.FormValue("destination") if unsafePath(rawSrc) || unsafePath(rawDst) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } src, dst := strings.Trim(rawSrc, "/"), strings.Trim(rawDst, "/") if src == "" || dst == "" || src == dst { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } if !a.MaskFor(src).HasRead() || (!copyOnly && !a.MaskFor(src).HasDelete()) || !a.MaskFor(dst).HasWrite() || !a.MaskFor(path.Dir(dst)).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } info, err := s.Store.Stat(src) if err != nil { http.Error(w, err.Error(), 404) return } if info.IsDir() && (dst == src || strings.HasPrefix(dst, src+"/")) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } if _, err = s.Store.Stat(dst); err == nil { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } else if !os.IsNotExist(err) { http.Error(w, err.Error(), 500) return } if info.IsDir() { if err = checkReadTree(s.Store, a, src); err != nil { writeJSONError(w, http.StatusForbidden, err.Error()) return } if !copyOnly { if err = checkDeleteTree(s.Store, a, src); err != nil { writeJSONError(w, http.StatusForbidden, err.Error()) return } } } if copyOnly { err = s.Store.Copy(src, dst) } else { err = s.Store.Rename(src, dst) } if err != nil { http.Error(w, err.Error(), 500) return } action := "moved" if copyOnly { action = "copied" } s.logOp(r, fmt.Sprintf("%s '%s' to '%s'", action, src, dst)) writeCreated(w) } func checkReadTree(store storage.Storage, a *auth.Account, root string) error { if !a.MaskFor(root).HasRead() { return fmt.Errorf("read permission denied") } info, err := store.Stat(root) if err != nil { return err } if !info.IsDir() { return nil } items, err := store.List(root) if err != nil { return err } for _, item := range items { if err := checkReadTree(store, a, path.Join(root, item.Name())); err != nil { return err } } return nil } func (s *Server) move(w http.ResponseWriter, r *http.Request) { s.transfer(w, r, false) } func (s *Server) copy(w http.ResponseWriter, r *http.Request) { s.transfer(w, r, 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"] } paths := make([]string, 0, len(items)) for _, raw := range items { if unsafePath(raw) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") if p == "" || !a.MaskFor(p).HasDelete() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } if e := checkDeleteTree(s.Store, a, p); e != nil { writeJSONError(w, http.StatusForbidden, "has no privilege") return } paths = append(paths, p) } // Preflight every target before mutating anything, so a multi-file delete // cannot partially succeed when a later item is forbidden. for _, p := range paths { if e := s.removePath(p); e != nil { http.Error(w, e.Error(), 500) return } s.logOp(r, fmt.Sprintf("removed '%s'", strings.Trim(p, "/"))) } w.WriteHeader(http.StatusNoContent) } 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) { raw := r.URL.Query().Get("file") if unsafePath(raw) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") _, e := s.Store.Stat(p) if e != nil { w.WriteHeader(http.StatusNotFound) return } w.WriteHeader(http.StatusCreated) } func (s *Server) session(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { jwtValue, _ := r.Cookie("JWT") userValue, _ := r.Cookie("user") jwt := "" user := "" if jwtValue != nil { jwt = jwtValue.Value } if userValue != nil { user = userValue.Value } http.SetCookie(w, &http.Cookie{Name: "JWT", Value: jwt, Path: "/", MaxAge: -1}) http.SetCookie(w, &http.Cookie{Name: "user", Value: user, Path: "/", MaxAge: -1}) w.WriteHeader(http.StatusNoContent) return } if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } user, pwd := r.FormValue("user"), r.FormValue("pwd") if user == "" && pwd == "" { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } a := s.Auth.Authenticate(user, pwd) if a == nil { writeJSONError(w, http.StatusNotFound, "user or password is wrong") return } token, err := auth.NewToken(a.Username, s.SessionTimeout) if err != nil { writeJSONError(w, http.StatusInternalServerError, "failed to create token") return } expires := time.Now().Add(365 * 24 * time.Hour) http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", Expires: expires, HttpOnly: true}) http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", Expires: expires}) w.WriteHeader(http.StatusCreated) } func (s *Server) me(w http.ResponseWriter, r *http.Request) { a := account(r, s) if a == nil { writeJSONError(w, http.StatusUnauthorized, "not authenticated") return } writeJSON(w, map[string]any{"username": a.Username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home)), "managed": a.PasswordHash != "", "enabled": a.Enabled}) } type accessibleRoot struct { Path string `json:"path"` Name string `json:"name"` Mask string `json:"mask"` Home bool `json:"home"` } func (s *Server) roots(w http.ResponseWriter, r *http.Request) { a := account(r, s) if a == nil || a.Username == "" { writeJSONError(w, http.StatusUnauthorized, "not authenticated") return } seen := map[string]bool{} out := make([]accessibleRoot, 0) if a.Admin && a.MaskFor("").HasRead() { seen[""] = true out = append(out, accessibleRoot{Path: "", Name: "全部文件", Mask: permString(a.MaskFor("")), Home: false}) } if a.Home != "" && a.MaskFor(a.Home).HasRead() { home := strings.Trim(a.Home, "/") seen[home] = true out = append(out, accessibleRoot{Path: home, Name: "我的空间", Mask: permString(a.MaskFor(home)), Home: true}) } for _, rule := range a.Rules { p := strings.Trim(rule.Dir, "/") if p == "" || seen[p] || rule.Deny || strings.ContainsAny(p, "*?") { continue } m := a.MaskFor(p) if !m.HasRead() { continue } seen[p] = true out = append(out, accessibleRoot{Path: p, Name: path.Base(p), Mask: permString(m), Home: false}) } writeJSON(w, map[string]any{"roots": out}) } func (s *Server) password(w http.ResponseWriter, r *http.Request) { a := account(r, s) if a == nil || a.Username == "" { writeJSONError(w, http.StatusUnauthorized, "not authenticated") return } if r.Method != http.MethodPost { w.WriteHeader(http.StatusMethodNotAllowed) return } var req struct { CurrentPassword string `json:"currentPassword"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json") return } if a.Password != req.CurrentPassword && a.PasswordHash != auth.PasswordHash(req.CurrentPassword) { writeJSONError(w, http.StatusBadRequest, "current password incorrect") return } if s.UserStore == nil { writeJSONError(w, http.StatusNotImplemented, "user management disabled") return } if err := s.UserStore.UpdatePassword(s.Auth, a.Username, req.Password); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } s.logOp(r, "changed password") writeJSON(w, map[string]any{"ok": true}) } type userRuleJSON struct { Dir string `json:"dir"` Mask string `json:"mask"` Deny bool `json:"deny,omitempty"` ChildrenOnly bool `json:"childrenOnly,omitempty"` } func userRulesJSON(rules []auth.PathRule) []userRuleJSON { out := make([]userRuleJSON, 0, len(rules)) for _, r := range rules { out = append(out, userRuleJSON{Dir: r.Dir, Mask: permString(r.Mask), Deny: r.Deny, ChildrenOnly: r.ChildrenOnly}) } return out } func (s *Server) users(w http.ResponseWriter, r *http.Request) { a := account(r, s) if a == nil || !a.Admin { writeJSONError(w, http.StatusForbidden, "admin required") return } if s.UserStore == nil { writeJSONError(w, http.StatusNotImplemented, "user management disabled") return } switch r.Method { case http.MethodGet: out := make([]map[string]any, 0, len(s.Auth.Accounts)) for _, u := range s.Auth.Accounts { if u.Username == "" || u == s.Auth.Guest { continue } out = append(out, map[string]any{"username": u.Username, "home": u.Home, "admin": u.Admin, "managed": u.PasswordHash != "", "enabled": u.Enabled, "rules": userRulesJSON(u.Rules)}) } writeJSON(w, map[string]any{"users": out}) case http.MethodPost: var req struct { Username string `json:"username"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json") return } home := path.Join(strings.Trim(s.UserHome, "/"), req.Username) if unsafePath(home) { writeJSONError(w, http.StatusBadRequest, "invalid username") return } u, err := s.UserStore.Add(s.Auth, req.Username, req.Password, home) if err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } if err := s.Store.MkdirAll(u.Home); err != nil { _ = s.UserStore.Delete(s.Auth, u.Username) writeJSONError(w, http.StatusInternalServerError, err.Error()) return } s.logOp(r, fmt.Sprintf("created user '%s'", u.Username)) writeJSON(w, map[string]any{"username": u.Username, "home": u.Home}) case http.MethodPut: var req struct { Username string `json:"username"` Password string `json:"password"` Enabled *bool `json:"enabled"` Rules []userRuleJSON `json:"rules"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid json") return } if err := auth.ValidateUsername(req.Username); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid username") return } var rules []auth.PathRule if req.Rules != nil { for _, rr := range req.Rules { dir := strings.Trim(strings.ReplaceAll(rr.Dir, "\\", "/"), "/") if dir == "" || unsafePath(dir) { writeJSONError(w, http.StatusBadRequest, "invalid rule path") return } rules = append(rules, auth.PathRule{Dir: dir, Mask: auth.ParseMask(rr.Mask), Deny: rr.Deny, ChildrenOnly: rr.ChildrenOnly}) } } if err := s.UserStore.Update(s.Auth, req.Username, req.Password, req.Enabled, rules); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } s.logOp(r, fmt.Sprintf("updated user '%s'", req.Username)) writeJSON(w, map[string]any{"ok": true}) case http.MethodDelete: username := r.URL.Query().Get("username") if err := auth.ValidateUsername(username); err != nil { writeJSONError(w, http.StatusBadRequest, "invalid username") return } if username == a.Username { writeJSONError(w, http.StatusBadRequest, "cannot delete current admin") return } if err := s.UserStore.Delete(s.Auth, username); err != nil { writeJSONError(w, http.StatusBadRequest, err.Error()) return } s.logOp(r, fmt.Sprintf("deleted user '%s'", username)) w.WriteHeader(http.StatusNoContent) default: w.WriteHeader(http.StatusMethodNotAllowed) } } func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawDir, rawTitle, rawFilename := r.FormValue("filepath"), r.FormValue("title"), r.FormValue("filename") if unsafePath(rawDir) || unsafePath(rawTitle) || unsafePath(rawFilename) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } dir := strings.Trim(strings.ReplaceAll(rawDir, "\\", "/"), "/") filename := strings.Trim(rawTitle, "/") if filename == "" { filename = strings.Trim(r.FormValue("filename"), "/") } if filename == "" { http.Error(w, "title required", 400) return } p := path.Join(dir, filename) if _, e := s.Store.Stat(p); e == nil { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } else if !os.IsNotExist(e) { http.Error(w, e.Error(), 500) return } if !a.MaskFor(p).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } f, e := createExclusive(s.Store, p) if e != nil { if os.IsExist(e) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } 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("create a text file:'%s'", p)) writeCreated(w) } func (s *Server) updatetext(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawDir, rawName := r.FormValue("filepath"), r.FormValue("filename") if unsafePath(rawDir) || unsafePath(rawName) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } dir := strings.Trim(strings.ReplaceAll(rawDir, "\\", "/"), "/") name := strings.Trim(strings.ReplaceAll(rawName, "\\", "/"), "/") p := path.Join(dir, name) if name == "" || !a.MaskFor(p).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } if e := copyFileToDestination(s.Store, strings.NewReader(r.FormValue("text")), p); e != nil { http.Error(w, e.Error(), 500) return } s.logOp(r, fmt.Sprintf("update text file:'%s'", p)) writeCreated(w) } 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) rawDir := r.FormValue("folder") if unsafePath(rawDir) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } dir := strings.Trim(strings.ReplaceAll(rawDir, "\\", "/"), "/") if !a.MaskFor(dir).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } // Preflight the complete multipart batch before creating anything. // This prevents duplicate/bad entries from leaving a partial upload. seen := make(map[string]struct{}) var saved []string for _, hs := range r.MultipartForm.File { for _, h := range hs { name := filepath.ToSlash(h.Filename) if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) { http.Error(w, "invalid filename", 400) return } target := path.Join(dir, name) if _, ok := seen[target]; ok { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } seen[target] = struct{}{} if _, e := s.Store.Stat(target); e == nil { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } else if !os.IsNotExist(e) { http.Error(w, e.Error(), 500) return } // Writing happens only after every part has passed validation. } } for _, hs := range r.MultipartForm.File { for _, h := range hs { target := path.Join(dir, filepath.ToSlash(h.Filename)) if e := savePart(s.Store, target, h); e != nil { for _, previous := range saved { _ = s.Store.Remove(previous) } http.Error(w, e.Error(), 500) return } saved = append(saved, target) } } for _, name := range saved { s.logOp(r, fmt.Sprintf("uploaded '%s'", filepath.Base(name))) } writeCreated(w) } func createExclusive(st storage.Storage, target string) (*os.File, error) { if x, ok := st.(storage.ExclusiveCreator); ok { return x.CreateExclusive(target) } if _, err := st.Stat(target); err == nil { return nil, os.ErrExist } else if !os.IsNotExist(err) { return nil, err } return st.Create(target) } 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 := createExclusive(st, target) if e != nil { return e } copyErr := func() error { _, err := io.Copy(f, in) return err }() closeErr := f.Close() if copyErr != nil { _ = st.Remove(target) return copyErr } if closeErr != nil { _ = st.Remove(target) return closeErr } return nil } func (s *Server) upload2(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawDir := r.URL.Query().Get("folder") if unsafePath(rawDir) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } dir := strings.Trim(strings.ReplaceAll(rawDir, "\\", "/"), "/") if !a.MaskFor(dir).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } if err := r.ParseMultipartForm(128 << 20); err != nil { http.Error(w, err.Error(), 400) return } // Preflight the complete multipart batch before creating anything. // This prevents duplicate/bad entries from leaving a partial upload. seen := make(map[string]struct{}) for _, hs := range r.MultipartForm.File { for _, h := range hs { name := filepath.ToSlash(h.Filename) name = strings.TrimPrefix(name, "/") if name == "" || unsafePath(name) { http.Error(w, "invalid filename", 400) return } target := path.Join(dir, name) if _, ok := seen[target]; ok { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } seen[target] = struct{}{} if _, e := s.Store.Stat(target); e == nil { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } else if !os.IsNotExist(e) { http.Error(w, e.Error(), 500) return } } } var saved []string for _, hs := range r.MultipartForm.File { for _, h := range hs { name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/") target := path.Join(dir, name) if err := savePart(s.Store, target, h); err != nil { for _, previous := range saved { _ = s.Store.Remove(previous) } http.Error(w, err.Error(), 500) return } saved = append(saved, target) } } writeCreated(w) } type searchFileInfo 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"` Icon string `json:"icon"` } func (s *Server) search(w http.ResponseWriter, r *http.Request) { a := account(r, s) rootMask := a.MaskFor("") if !rootMask.HasRead() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str"))) out := make([]searchFileInfo, 0) 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 needle == "" || strings.Contains(strings.ToLower(fi.Name()), needle) { out = append(out, searchFileInfo{Name: fi.Name(), Path: "/" + strings.Trim(p, "/"), Size: fi.Size(), Modified: originalTime(fi.ModTime()), Dir: fi.IsDir(), Mask: permString(a.MaskForEntry(p)), GuestMask: permString(s.Auth.Guest.MaskForEntry(p)), HasSubDir: false, Icon: iconFor(fi)}) } 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{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out}) } func (s *Server) shared(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/chfs/shared" { location := "shared/" if r.URL.RawQuery != "" { location += "?" + r.URL.RawQuery } w.Header().Set("Content-Disposition", `attachment;filename="shared"`) w.Header().Set("Location", location) w.WriteHeader(http.StatusMovedPermanently) return } p := strings.Trim(strings.TrimPrefix(r.URL.Path, "/chfs/shared/"), "/") if unsafePath(p) || !account(r, s).MaskFor(p).HasRead() { http.NotFound(w, r) return } fi, err := s.Store.Stat(p) if err != nil { http.NotFound(w, r) return } if fi.IsDir() { w.Header().Set("Content-Disposition", `attachment;filename="shared"`) w.Header().Set("Content-Type", "text/html; charset=utf-8") w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) items, err := s.Store.List(p) if err != nil { http.NotFound(w, r) return } _, _ = io.WriteString(w, "
\n")
		for _, item := range items {
			name := item.Name()
			href := url.PathEscape(name)
			if item.IsDir() {
				href += "/"
			}
			_, _ = fmt.Fprintf(w, "%s\n", href, html.EscapeString(name)+func() string {
				if item.IsDir() {
					return "/"
				}
				return ""
			}())
		}
		_, _ = io.WriteString(w, "
\n") return } if r.Method != http.MethodGet { http.NotFound(w, r) return } f, err := s.Store.Open(p) if err != nil { http.NotFound(w, r) return } defer f.Close() w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, fi.Name())) http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) } func (s *Server) preview(w http.ResponseWriter, r *http.Request) { rawFile := r.URL.Query().Get("file") if unsafePath(rawFile) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } file := strings.Trim(strings.ReplaceAll(rawFile, "\\", "/"), "/") if file == "" { http.Error(w, "file required", 400) return } if !account(r, s).MaskFor(file).HasRead() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } if !s.ImagePreview { writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") return } f, err := s.Store.Open(file) if err != nil { writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") return } defer f.Close() src, _, err := image.Decode(f) if err != nil { writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") return } thumb := transform.Resize(src, 64, 64, transform.Linear) var buf bytes.Buffer if err := png.Encode(&buf, thumb); err != nil { writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") return } w.Header().Set("Cache-Control", "max-age=2592000") w.Header().Set("Content-Type", "image/png") _, _ = w.Write(buf.Bytes()) } func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/" { http.NotFound(w, r) return } title := s.HTMLTitle if title == "" { title = "CuteHttpFileServer" } notice := s.HTMLNotice if strings.HasPrefix(notice, "`") && strings.HasSuffix(notice, "`") && len(notice) >= 2 { notice = notice[1 : len(notice)-1] } else { notice = html.EscapeString(notice) } page := bytes.ReplaceAll(webIndex, []byte("__CHFS_TITLE__"), []byte(html.EscapeString(title))) page = bytes.ReplaceAll(page, []byte("__CHFS_NOTICE__"), []byte(notice)) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = w.Write(page) } func (s *Server) apiDoc(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/asset/api.html" { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/html; charset=utf-8") const doc = `CHFS API

CHFS API

Compatibility API implemented by chfs-ng.

` _, _ = w.Write([]byte(doc)) } 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() { writeJSONError(w, http.StatusForbidden, "has no privilege") 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) qrcode(w http.ResponseWriter, r *http.Request) { u := r.URL.Query().Get("url") if u == "" || len(u) > 4096 { writeJSONError(w, http.StatusBadRequest, "invalid url") return } data, err := qrcode.Encode(u, qrcode.Medium, 320) if err != nil { writeJSONError(w, http.StatusInternalServerError, "qrcode failed") return } w.Header().Set("Cache-Control", "no-store") w.Header().Set("Content-Type", "image/png") _, _ = w.Write(data) } func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) { a := account(r, s) paths := r.URL.Query()["path"] if len(paths) == 0 || len(paths) > 100 { writeJSONError(w, http.StatusBadRequest, "invalid selection") return } seen := map[string]bool{} for i, raw := range paths { p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") if p == "" || unsafePath(p) || seen[p] { writeJSONError(w, http.StatusBadRequest, "invalid selection") return } seen[p] = true paths[i] = p if !a.MaskFor(p).HasRead() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } if _, err := s.Store.Stat(p); err != nil { http.NotFound(w, r) return } } w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) zw := zip.NewWriter(w) defer zw.Close() added := map[string]bool{} var walk func(string, string) error walk = func(p, prefix string) error { fi, err := s.Store.Stat(p) if err != nil { return err } if fi.IsDir() { items, err := s.Store.List(p) if err != nil { return err } for _, it := range items { child := path.Join(p, it.Name()) if !a.MaskFor(child).HasRead() { continue } if err := walk(child, path.Join(prefix, it.Name())); err != nil { return err } } return nil } if added[prefix] { return nil } added[prefix] = true f, err := s.Store.Open(p) if err != nil { return err } defer f.Close() h := &zip.FileHeader{Name: prefix, Method: zip.Deflate} h.SetModTime(fi.ModTime()) wz, err := zw.CreateHeader(h) if err != nil { return err } _, err = io.Copy(wz, f) return err } for _, p := range paths { if err := walk(p, path.Base(p)); err != nil { return } } } func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) { a := account(r, s) rawPath := strings.TrimPrefix(r.URL.Path, "/chfs/downloaddir/") if unsafePath(rawPath) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } p := strings.Trim(rawPath, "/") if !a.MaskFor(p).HasRead() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } fi, err := s.Store.Stat(p) if err != nil || !fi.IsDir() { http.NotFound(w, r) return } mode := strings.ToLower(strings.TrimSpace(s.FolderDownload)) if mode == "" { mode = "enable" } if mode == "disable" || mode == "false" || mode == "0" { writeJSONError(w, http.StatusForbidden, "folder download disabled") return } if mode == "leaf" { items, err := s.Store.List(p) if err != nil { http.Error(w, err.Error(), 500) return } for _, item := range items { if item.IsDir() { writeJSONError(w, http.StatusForbidden, "folder download is only available for leaf directories") return } } } name := fi.Name() if name == "." || name == "" { name = "download" } w.Header().Set("Content-Type", "application/zip") // CHFS v4 does not emit Content-Disposition for directory archives. 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 } header := &zip.FileHeader{Name: rel, Method: zip.Deflate} header.SetModTime(item.ModTime()) h, err := zw.CreateHeader(header) 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("downloaded '/%s'", strings.Trim(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.Trim(strings.TrimPrefix(r.URL.Path, "/webdav"), "/") if unsafePath(vp) { writeJSONError(w, http.StatusBadRequest, "invalid parameter") return } old := r.URL.Path r.URL.Path = "/" + vp defer func() { r.URL.Path = old }() inner := s.WebDAVFS if inner == nil { inner = &webdavStorageFS{store: s.Store, removeMode: s.RemoveMode} } fs := &webdavAuthFS{inner: inner, account: account(r, s)} if (r.Method == "COPY" || r.Method == "MOVE") && !sameWebDAVDestinationHost(r) { writeJSONError(w, http.StatusBadRequest, "invalid destination") return } (&webdav.Handler{FileSystem: fs}).ServeHTTP(w, r) } func sameWebDAVDestinationHost(r *http.Request) bool { dest := strings.TrimSpace(r.Header.Get("Destination")) if dest == "" { return false } u, err := url.Parse(dest) if err != nil { return false } if u.Host == "" { return true } return strings.EqualFold(u.Host, r.Host) } type webdavStorageFS struct { store storage.Storage removeMode int } func davStoragePath(name string) string { p := strings.Trim(strings.ReplaceAll(name, "\\", "/"), "/") p = strings.TrimPrefix(p, "webdav/") p = strings.TrimPrefix(p, "webdav") return strings.Trim(p, "/") } func davSafePath(name string) (string, error) { p := davStoragePath(name) if unsafePath(p) { return "", webdav.NewHTTPError(http.StatusBadRequest, fmt.Errorf("invalid parameter")) } return p, nil } func sameOrDescendantPath(src, dst string) bool { src = strings.Trim(path.Clean("/"+src), "/") dst = strings.Trim(path.Clean("/"+dst), "/") return src == dst || (src != "" && strings.HasPrefix(dst, src+"/")) } func (f *webdavStorageFS) info(name string) (*webdav.FileInfo, error) { p, err := davSafePath(name) if err != nil { return nil, err } fi, err := f.store.Stat(p) if err != nil { return nil, err } return &webdav.FileInfo{Path: "/" + p, Size: fi.Size(), ModTime: fi.ModTime(), IsDir: fi.IsDir()}, nil } func (f *webdavStorageFS) Open(ctx context.Context, name string) (io.ReadCloser, error) { p, err := davSafePath(name) if err != nil { return nil, err } fi, err := f.store.Stat(p) if err != nil { return nil, err } if fi.IsDir() { return nil, fmt.Errorf("is a directory") } return f.store.Open(p) } func (f *webdavStorageFS) Stat(ctx context.Context, name string) (*webdav.FileInfo, error) { return f.info(name) } func (f *webdavStorageFS) ReadDir(ctx context.Context, name string, recursive bool) ([]webdav.FileInfo, error) { root, err := davSafePath(name) if err != nil { return nil, err } var out []webdav.FileInfo var walk func(string) error walk = func(dir string) error { items, err := f.store.List(dir) if err != nil { return err } for _, item := range items { p := path.Join(dir, item.Name()) out = append(out, webdav.FileInfo{Path: "/" + p, Size: item.Size(), ModTime: item.ModTime(), IsDir: item.IsDir()}) if recursive && item.IsDir() { if err := walk(p); err != nil { return err } } } return nil } if err := walk(root); err != nil { return nil, err } return out, nil } func (f *webdavStorageFS) Create(ctx context.Context, name string, body io.ReadCloser, opts *webdav.CreateOptions) (*webdav.FileInfo, bool, error) { p, err := davSafePath(name) if err != nil { return nil, false, err } if isVirtualStorageRoot(f.store, p) { return nil, false, virtualRootMutationError() } _, statErr := f.store.Stat(p) if statErr != nil && !os.IsNotExist(statErr) { return nil, false, statErr } exclusive := opts != nil && opts.IfNoneMatch.IsWildcard() if exclusive { file, createErr := createExclusive(f.store, p) if createErr != nil { if os.IsExist(createErr) { return nil, false, webdav.NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("destination exists")) } return nil, false, createErr } _, copyErr := io.Copy(file, body) closeErr := file.Close() body.Close() if copyErr != nil { _ = f.store.Remove(p) return nil, false, copyErr } if closeErr != nil { _ = f.store.Remove(p) return nil, false, closeErr } } else { // Write into a sibling temporary file and atomically replace the // destination only after the complete request body has been received. // A failed PUT therefore cannot truncate or corrupt the old file. if err := copyFileToDestination(f.store, body, p); err != nil { body.Close() return nil, false, err } body.Close() } fi, err := f.info(p) return fi, true, err } func isVirtualStorageRoot(store storage.Storage, p string) bool { m, ok := store.(storage.Multi) if !ok { return false } _, rel, found := m.FindRoot(p) return found && rel == "" } func virtualRootMutationError() error { return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot modify virtual root")) } func (f *webdavStorageFS) RemoveAll(ctx context.Context, name string, opts *webdav.RemoveAllOptions) error { p, err := davSafePath(name) if err != nil { return err } if p == "" || isVirtualStorageRoot(f.store, p) { return virtualRootMutationError() } if _, err := f.store.Stat(p); err != nil { if os.IsNotExist(err) { return webdav.NewHTTPError(http.StatusNotFound, err) } return err } if f.removeMode == 1 { if t, ok := f.store.(storage.Trashable); ok { return t.Trash(p) } } if f.removeMode == 2 { if t, ok := f.store.(storage.Trashable); ok { home, err := os.UserHomeDir() if err != nil { return err } return t.MoveToTrash(p, filepath.Join(home, ".chfs_trashbin")) } } return f.store.Remove(p) } func (f *webdavStorageFS) Mkdir(ctx context.Context, name string) error { p, err := davSafePath(name) if err != nil { return err } if p == "" || isVirtualStorageRoot(f.store, p) { return virtualRootMutationError() } if _, err := f.store.Stat(p); err == nil { return webdav.NewHTTPError(http.StatusMethodNotAllowed, fmt.Errorf("resource already exists")) } else if !os.IsNotExist(err) { return err } return f.store.MkdirAll(p) } func (f *webdavStorageFS) Copy(ctx context.Context, name, dest string, opts *webdav.CopyOptions) (bool, error) { src, err := davSafePath(name) if err != nil { return false, err } dst, err := davSafePath(dest) if err != nil { return false, err } if sameOrDescendantPath(src, dst) { return false, webdav.NewHTTPError(http.StatusConflict, fmt.Errorf("destination is inside source")) } if isVirtualStorageRoot(f.store, src) || isVirtualStorageRoot(f.store, dst) { return false, virtualRootMutationError() } _, dstErr := f.store.Stat(dst) dstExists := dstErr == nil if dstErr != nil && !os.IsNotExist(dstErr) { return false, dstErr } if opts != nil && opts.NoOverwrite && dstExists { return false, webdav.NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("destination exists")) } if m, ok := f.store.(storage.Multi); ok { srcRoot, _, srcOK := m.FindRoot(src) dstRoot, _, dstOK := m.FindRoot(dst) if srcOK && dstOK && srcRoot != dstRoot { return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root copy is not supported")) } } info, err := f.store.Stat(src) if err != nil { return false, err } if info.IsDir() { if opts != nil && opts.NoOverwrite { if err := preflightCopyNoOverwrite(f.store, src, dst); err != nil { return false, err } } if err := f.store.MkdirAll(dst); err != nil { return false, err } items, err := f.store.List(src) if err != nil { if !dstExists { _ = f.store.Remove(dst) } return false, err } for _, item := range items { if _, err := f.Copy(ctx, path.Join(src, item.Name()), path.Join(dst, item.Name()), opts); err != nil { if !dstExists { _ = f.store.Remove(dst) } return false, err } } return !dstExists, nil } in, err := f.store.Open(src) if err != nil { return false, err } defer in.Close() if err := copyFileToDestination(f.store, in, dst); err != nil { return false, err } return !dstExists, nil } func preflightCopyNoOverwrite(st storage.Storage, src, dst string) error { if _, err := st.Stat(dst); err == nil { return webdav.NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("destination exists")) } else if !os.IsNotExist(err) { return err } info, err := st.Stat(src) if err != nil { return err } if !info.IsDir() { return nil } items, err := st.List(src) if err != nil { return err } for _, item := range items { if err := preflightCopyNoOverwrite(st, path.Join(src, item.Name()), path.Join(dst, item.Name())); err != nil { return err } } return nil } func copyFileToDestination(st storage.Storage, src io.Reader, dst string) error { var tmp string var out *os.File for i := 0; i < 16; i++ { suffix := fmt.Sprintf("%d", time.Now().UnixNano()) if i > 0 { suffix = fmt.Sprintf("%s-%d", suffix, i) } candidate := fmt.Sprintf("%s.chfs-copy-%s", dst, suffix) var err error out, err = createExclusive(st, candidate) if err == nil { tmp = candidate break } if !os.IsExist(err) { return err } } if out == nil { return fmt.Errorf("unable to allocate temporary copy path") } _, copyErr := io.Copy(out, src) closeErr := out.Close() if copyErr != nil { _ = st.Remove(tmp) return copyErr } if closeErr != nil { _ = st.Remove(tmp) return closeErr } if err := st.Rename(tmp, dst); err != nil { _ = st.Remove(tmp) return err } return nil } func (f *webdavStorageFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) { src, err := davSafePath(name) if err != nil { return false, err } dst, err := davSafePath(dest) if err != nil { return false, err } if sameOrDescendantPath(src, dst) { return false, webdav.NewHTTPError(http.StatusConflict, fmt.Errorf("destination is inside source")) } if isVirtualStorageRoot(f.store, src) || isVirtualStorageRoot(f.store, dst) { return false, virtualRootMutationError() } if _, err := f.store.Stat(dst); err == nil { // CHFS v4 MOVE does not overwrite an existing destination, even when // Overwrite is omitted/T and the caller has delete permission. return false, webdav.NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("destination exists")) } else if !os.IsNotExist(err) { return false, err } if m, ok := f.store.(storage.Multi); ok { srcRoot, _, srcOK := m.FindRoot(src) dstRoot, _, dstOK := m.FindRoot(dst) if srcOK && dstOK && srcRoot != dstRoot { return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root move is not supported")) } } if _, err := f.store.Stat(src); err != nil { if os.IsNotExist(err) { return false, webdav.NewHTTPError(http.StatusNotFound, err) } return false, err } if err := f.store.Rename(src, dst); err != nil { if os.IsNotExist(err) { return false, webdav.NewHTTPError(http.StatusNotFound, err) } return false, webdav.NewHTTPError(http.StatusForbidden, err) } return true, nil } type webdavAuthFS struct { inner webdav.FileSystem account *auth.Account } func webdavAuthPath(p string) string { p = strings.Trim(strings.ReplaceAll(p, "\\", "/"), "/") p = strings.TrimPrefix(p, "webdav/") p = strings.TrimPrefix(p, "webdav") return strings.Trim(p, "/") } func (f *webdavAuthFS) allow(path string, p auth.PermMask) error { path = webdavAuthPath(path) if f.account == nil || f.account.MaskFor(path)&p != p { return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("forbidden")) } return nil } func (f *webdavAuthFS) Open(ctx context.Context, name string) (io.ReadCloser, error) { if err := f.allow(name, auth.Read); err != nil { return nil, err } return f.inner.Open(ctx, name) } func (f *webdavAuthFS) Stat(ctx context.Context, name string) (*webdav.FileInfo, error) { if err := f.allow(name, auth.Read); err != nil { return nil, err } return f.inner.Stat(ctx, name) } func (f *webdavAuthFS) ReadDir(ctx context.Context, name string, recursive bool) ([]webdav.FileInfo, error) { if err := f.allow(name, auth.Read); err != nil { return nil, err } items, err := f.inner.ReadDir(ctx, name, recursive) if err != nil { return nil, err } out := make([]webdav.FileInfo, 0, len(items)) for _, item := range items { p := strings.Trim(item.Path, "/") if f.account.MaskFor(p).HasRead() { out = append(out, item) } } return out, nil } func (f *webdavAuthFS) Create(ctx context.Context, name string, body io.ReadCloser, opts *webdav.CreateOptions) (*webdav.FileInfo, bool, error) { parent := path.Dir(strings.Trim(name, "/")) if parent == "." { parent = "" } if err := f.allow(parent, auth.Write); err != nil { return nil, false, err } // CHFS permits PUT to overwrite an existing file only when the target // itself is deletable. A writable parent alone is enough for creation. if _, err := f.inner.Stat(ctx, name); err == nil { if err := f.allow(name, auth.Delete); err != nil { return nil, false, err } } else if !os.IsNotExist(err) { return nil, false, err } return f.inner.Create(ctx, name, body, opts) } func (f *webdavAuthFS) RemoveAll(ctx context.Context, name string, opts *webdav.RemoveAllOptions) error { if err := f.allow(name, auth.Delete); err != nil { return err } if err := f.checkTree(ctx, name, auth.Delete); err != nil { return err } return f.inner.RemoveAll(ctx, name, opts) } func (f *webdavAuthFS) Mkdir(ctx context.Context, name string) error { parent := path.Dir(strings.Trim(name, "/")) if parent == "." { parent = "" } if err := f.allow(parent, auth.Write); err != nil { return err } return f.inner.Mkdir(ctx, name) } func (f *webdavAuthFS) Copy(ctx context.Context, name, dest string, opts *webdav.CopyOptions) (bool, error) { if err := f.allow(name, auth.Read); err != nil { return false, err } if err := f.checkTree(ctx, name, auth.Read); err != nil { return false, err } parent := path.Dir(webdavAuthPath(dest)) if parent == "." { parent = "" } if err := f.allow(parent, auth.Write); err != nil { return false, err } if opts == nil || !opts.NoOverwrite { if err := f.authorizeOverwrite(ctx, dest); err != nil { return false, err } } return f.inner.Copy(ctx, name, dest, opts) } func (f *webdavAuthFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) { // CHFS treats MOVE as a write operation on the source; W already // includes read permission and does not require explicit delete permission. if err := f.allow(name, auth.Write); err != nil { return false, err } parent := path.Dir(webdavAuthPath(dest)) if parent == "." { parent = "" } if err := f.allow(parent, auth.Write); err != nil { return false, err } // CHFS checks an existing destination before resolving a missing source. // This matters because MOVE to an occupied destination returns 412 even // when the source has disappeared, while a missing source with a free // destination is reported as 403. destExists := false if _, err := f.inner.Stat(ctx, dest); err == nil { destExists = true } else if !isWebDAVNotFound(err) { return false, err } if destExists { if opts == nil || !opts.NoOverwrite { if err := f.authorizeOverwrite(ctx, dest); err != nil { return false, err } } return false, webdav.NewHTTPError(http.StatusPreconditionFailed, fmt.Errorf("destination exists")) } if _, err := f.inner.Stat(ctx, name); err != nil { if os.IsNotExist(err) { return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("source not found")) } return false, err } if err := f.checkTree(ctx, name, auth.Delete); err != nil { return false, err } return f.inner.Move(ctx, name, dest, opts) } func isWebDAVNotFound(err error) bool { if err == nil { return false } return os.IsNotExist(err) || strings.HasPrefix(err.Error(), "404 Not Found:") } func (f *webdavAuthFS) authorizeOverwrite(ctx context.Context, dest string) error { info, err := f.inner.Stat(ctx, dest) if err != nil { if os.IsNotExist(err) { return nil } return err } if err := f.allow(dest, auth.Delete); err != nil { return err } if info.IsDir { return f.checkTree(ctx, dest, auth.Delete) } return nil } func (f *webdavAuthFS) checkTree(ctx context.Context, name string, p auth.PermMask) error { info, err := f.inner.Stat(ctx, name) if err != nil { if os.IsNotExist(err) { return webdav.NewHTTPError(http.StatusNotFound, err) } return err } if !info.IsDir { return nil } items, err := f.inner.ReadDir(ctx, name, true) if err != nil { return err } for _, item := range items { itemPath := strings.Trim(item.Path, "/") if err := f.allow(itemPath, p); err != nil { return err } } return nil } 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 }