package api import ( "archive/zip" "bytes" "context" "encoding/json" "fmt" "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" ) 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 Log *opslog.Logger } 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("/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/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 { 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 { 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 (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 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) 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 _, 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 := 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) 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) 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 !a.MaskFor(p).HasWrite() { writeJSONError(w, http.StatusForbidden, "has no privilege") 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)) 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 } 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)) 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 } 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 } 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("uploaded '%s'", filepath.Base(name))) } writeCreated(w) } 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) 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 } var saved []string 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 } 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)) } } 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 } http.Redirect(w, r, location, http.StatusMovedPermanently) return } p := strings.Trim(strings.TrimPrefix(r.URL.Path, "/chfs/shared/"), "/") if unsafePath(p) { http.NotFound(w, r) return } fi, err := s.Store.Stat(p) if err != nil { http.NotFound(w, r) return } if fi.IsDir() { items, err := s.Store.List(p) if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Disposition", `attachment;filename="shared"`) w.Header().Set("Content-Type", "text/html; charset=utf-8") _, _ = 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) {
http.NotFound(w, r)
}
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) 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
}
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("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)}
(&webdav.Handler{FileSystem: fs}).ServeHTTP(w, r)
}
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 (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
}
_, statErr := f.store.Stat(p)
if statErr != nil && !os.IsNotExist(statErr) {
return nil, false, statErr
}
file, err := f.store.Create(p)
if err != nil {
return nil, false, err
}
_, copyErr := io.Copy(file, body)
closeErr := file.Close()
body.Close()
if copyErr != nil {
return nil, false, copyErr
}
if closeErr != nil {
return nil, false, closeErr
}
fi, err := f.info(p)
return fi, true, err
}
func (f *webdavStorageFS) RemoveAll(ctx context.Context, name string, opts *webdav.RemoveAllOptions) error {
p, err := davSafePath(name)
if err != nil {
return err
}
if p == "" {
return fmt.Errorf("cannot remove storage root")
}
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 == "" {
return fmt.Errorf("cannot create storage root")
}
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 opts != nil && opts.NoOverwrite {
if _, err := f.store.Stat(dst); err == nil {
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 copy is not supported"))
}
}
info, err := f.store.Stat(src)
if err != nil {
return false, err
}
if info.IsDir() {
if err := f.store.MkdirAll(dst); err != nil {
return false, err
}
items, err := f.store.List(src)
if err != nil {
return false, err
}
for _, item := range items {
created, err := f.Copy(ctx, path.Join(src, item.Name()), path.Join(dst, item.Name()), opts)
if err != nil {
return false, err
}
_ = created
}
return true, nil
}
in, err := f.store.Open(src)
if err != nil {
return false, err
}
out, err := f.store.Create(dst)
if err != nil {
in.Close()
return false, err
}
_, copyErr := io.Copy(out, in)
in.Close()
closeErr := out.Close()
if copyErr != nil {
return false, copyErr
}
if closeErr != nil {
return false, closeErr
}
return true, 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 opts == nil || opts.NoOverwrite {
if _, err := f.store.Stat(dst); err == nil {
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 webdavMultiFS struct {
roots map[string]webdav.FileSystem
names []string
}
func newWebDAVMultiFS(m storage.Multi) *webdavMultiFS {
fs := &webdavMultiFS{roots: make(map[string]webdav.FileSystem), names: append([]string(nil), m.Names...)}
for i, name := range m.Names {
if i < len(m.Roots) {
fs.roots[name] = webdav.LocalFileSystem(m.Roots[i].Root)
}
}
return fs
}
func splitDAVPath(name string) (string, string) {
p := strings.Trim(strings.ReplaceAll(name, "\\\\", "/"), "/")
p = strings.TrimPrefix(p, "webdav/")
p = strings.TrimPrefix(p, "webdav")
if p == "" {
return "", ""
}
parts := strings.SplitN(p, "/", 2)
if len(parts) == 1 {
return parts[0], ""
}
return parts[0], parts[1]
}
func (f *webdavMultiFS) root(name string) (webdav.FileSystem, string, error) {
r, rel := splitDAVPath(name)
if r == "" {
return nil, "", nil
}
fs, ok := f.roots[r]
if !ok {
return nil, "", webdav.NewHTTPError(http.StatusNotFound, fmt.Errorf("root not found"))
}
return fs, rel, nil
}
func (f *webdavMultiFS) Open(ctx context.Context, name string) (io.ReadCloser, error) {
fs, rel, err := f.root(name)
if err != nil {
return nil, err
}
if fs == nil {
return nil, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("virtual root is not a file"))
}
return fs.Open(ctx, "/"+rel)
}
func (f *webdavMultiFS) Stat(ctx context.Context, name string) (*webdav.FileInfo, error) {
if strings.Trim(strings.ReplaceAll(name, "\\\\", "/"), "/") == "" {
return &webdav.FileInfo{Path: "/", IsDir: true}, nil
}
fs, rel, err := f.root(name)
if err != nil {
return nil, err
}
if rel == "" {
return &webdav.FileInfo{Path: "/" + strings.Trim(strings.SplitN(strings.Trim(name, "/"), "/", 2)[0], "/"), IsDir: true}, nil
}
fi, err := fs.Stat(ctx, "/"+rel)
if err != nil {
return nil, err
}
fi.Path = "/" + strings.Trim(name, "/")
return fi, nil
}
func (f *webdavMultiFS) ReadDir(ctx context.Context, name string, recursive bool) ([]webdav.FileInfo, error) {
p := strings.Trim(strings.ReplaceAll(name, "\\\\", "/"), "/")
if p == "" {
out := make([]webdav.FileInfo, 0, len(f.names))
for _, n := range f.names {
out = append(out, webdav.FileInfo{Path: "/" + n, IsDir: true})
}
return out, nil
}
fs, rel, err := f.root(p)
if err != nil {
return nil, err
}
items, err := fs.ReadDir(ctx, "/"+rel, recursive)
if err != nil {
return nil, err
}
root, _ := splitDAVPath(p)
for i := range items {
itemPath := strings.TrimPrefix(items[i].Path, "/")
if itemPath == "" {
itemPath = filepath.Base(items[i].Path)
}
items[i].Path = "/" + path.Join(root, itemPath)
}
return items, nil
}
func (f *webdavMultiFS) Create(ctx context.Context, name string, body io.ReadCloser, opts *webdav.CreateOptions) (*webdav.FileInfo, bool, error) {
fs, rel, err := f.root(name)
if err != nil {
return nil, false, err
}
if fs == nil || rel == "" {
return nil, false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot create virtual root"))
}
fi, created, err := fs.Create(ctx, "/"+rel, body, opts)
if err == nil {
fi.Path = "/" + strings.Trim(name, "/")
}
return fi, created, err
}
func (f *webdavMultiFS) RemoveAll(ctx context.Context, name string, opts *webdav.RemoveAllOptions) error {
fs, rel, err := f.root(name)
if err != nil {
return err
}
if fs == nil || rel == "" {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot remove virtual root"))
}
return fs.RemoveAll(ctx, "/"+rel, opts)
}
func (f *webdavMultiFS) Mkdir(ctx context.Context, name string) error {
fs, rel, err := f.root(name)
if err != nil {
return err
}
if fs == nil || rel == "" {
return webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cannot create virtual root"))
}
return fs.Mkdir(ctx, "/"+rel)
}
func (f *webdavMultiFS) Copy(ctx context.Context, name, dest string, opts *webdav.CopyOptions) (bool, error) {
fs, rel, err := f.root(name)
if err != nil {
return false, err
}
dfs, drel, err := f.root(dest)
if err != nil {
return false, err
}
if fs == nil || dfs == nil || fs != dfs {
return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root copy is not supported"))
}
return fs.Copy(ctx, "/"+rel, "/"+drel, opts)
}
func (f *webdavMultiFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) {
fs, rel, err := f.root(name)
if err != nil {
return false, err
}
dfs, drel, err := f.root(dest)
if err != nil {
return false, err
}
if fs == nil || dfs == nil || fs != dfs {
return false, webdav.NewHTTPError(http.StatusForbidden, fmt.Errorf("cross-root move is not supported"))
}
return fs.Move(ctx, "/"+rel, "/"+drel, opts)
}
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
}
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
}
return f.inner.Copy(ctx, name, dest, opts)
}
func (f *webdavAuthFS) Move(ctx context.Context, name, dest string, opts *webdav.MoveOptions) (bool, error) {
if err := f.allow(name, auth.Delete); err != nil {
return false, err
}
if err := f.checkTree(ctx, name, auth.Delete); 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
}
return f.inner.Move(ctx, name, dest, opts)
}
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
}