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 = `Compatibility API implemented by chfs-ng.