From 79070af15df5bc4cf0c4998f6079f28969989dda Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Mon, 21 Sep 2026 13:02:34 +0800 Subject: [PATCH] feat: implement legacy config auth storage and core api --- chfs-ng/cmd/chfs/main.go | 58 +++++++- chfs-ng/internal/api/server.go | 220 ++++++++++++++++++++++++++-- chfs-ng/internal/auth/auth.go | 115 +++++++++++++++ chfs-ng/internal/auth/auth_test.go | 20 +++ chfs-ng/internal/config/config.go | 128 ++++++++++++++++ chfs-ng/internal/storage/storage.go | 85 +++++++++-- 6 files changed, 600 insertions(+), 26 deletions(-) create mode 100644 chfs-ng/internal/auth/auth_test.go create mode 100644 chfs-ng/internal/config/config.go diff --git a/chfs-ng/cmd/chfs/main.go b/chfs-ng/cmd/chfs/main.go index 492a17d..59d6614 100644 --- a/chfs-ng/cmd/chfs/main.go +++ b/chfs-ng/cmd/chfs/main.go @@ -1,14 +1,62 @@ package main import ( - "chfs-ng/internal/api" - "chfs-ng/internal/storage" + "flag" "log" "net/http" + "os" + "path/filepath" + + "chfs-ng/internal/api" + "chfs-ng/internal/auth" + "chfs-ng/internal/config" + "chfs-ng/internal/storage" ) func main() { - s := &api.Server{Store: storage.Local{Root: "/data"}} - log.Println("chfs-ng development server listening on :80") - log.Fatal(http.ListenAndServe(":80", s.Handler())) + file := flag.String("file", "", "configuration file") + pathFlag := flag.String("path", "/data", "shared path") + port := flag.Int("port", 80, "listen port") + flag.Parse() + cfg := config.Default() + if *file != "" { + var err error + cfg, err = config.Load(*file) + if err != nil && !os.IsNotExist(err) { + log.Fatal(err) + } + } + if *pathFlag != "/data" || *file == "" { + if *file == "" { + cfg.Path = []string{*pathFlag} + cfg.Port = *port + } + } + root := cfg.Path[0] + if e := os.MkdirAll(root, 0755); e != nil { + log.Fatal(e) + } + mgr, _ := auth.ParseRules(cfg.Rules) + srv := &api.Server{Store: storage.Local{Root: root}, Auth: mgr, Sessions: map[string]*auth.Account{}} + addr := ":" + itoa(cfg.Port) + if cfg.Port == 0 { + addr = ":" + itoa(*port) + } + log.Printf("chfs-ng listening on %s root=%s", addr, filepath.Clean(root)) + if cfg.SSLCert != "" && cfg.SSLKey != "" { + log.Fatal(http.ListenAndServeTLS(addr, cfg.SSLCert, cfg.SSLKey, srv.Handler())) + } + log.Fatal(http.ListenAndServe(addr, srv.Handler())) +} +func itoa(n int) string { + const d = "0123456789" + if n == 0 { + return "0" + } + b := make([]byte, 0, 6) + for n > 0 { + b = append([]byte{d[n%10]}, b...) + n /= 10 + } + return string(b) } diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 883d626..3ab97e8 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -1,11 +1,33 @@ package api import ( - "chfs-ng/internal/storage" + "encoding/json" + "io" + "mime/multipart" "net/http" + "path" + "strings" + "time" + + "chfs-ng/internal/auth" + "chfs-ng/internal/storage" ) -type Server struct{ Store storage.Storage } +type Server struct { + Store storage.Storage + Auth *auth.Manager + Sessions map[string]*auth.Account +} +type fileInfo struct { + Name string `json:"name"` + Path string `json:"path"` + Size int64 `json:"size"` + Modified string `json:"modified"` + Dir bool `json:"dir"` + Mask string `json:"mask"` + GuestMask string `json:"guestMask"` + HasSubDir bool `json:"hasSubDir"` +} func (s *Server) Handler() http.Handler { mux := http.NewServeMux() @@ -15,12 +37,192 @@ func (s *Server) Handler() http.Handler { 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.upload) return mux } -func notImpl(w http.ResponseWriter) { http.Error(w, "not implemented", http.StatusNotImplemented) } -func (s *Server) files(w http.ResponseWriter, r *http.Request) { notImpl(w) } -func (s *Server) newdir(w http.ResponseWriter, r *http.Request) { notImpl(w) } -func (s *Server) rename(w http.ResponseWriter, r *http.Request) { notImpl(w) } -func (s *Server) remove(w http.ResponseWriter, r *http.Request) { notImpl(w) } -func (s *Server) exist(w http.ResponseWriter, r *http.Request) { notImpl(w) } -func (s *Server) session(w http.ResponseWriter, r *http.Request) { notImpl(w) } +func account(r *http.Request, s *Server) *auth.Account { + if c, e := r.Cookie("chfs_token"); e == nil { + if a := s.Sessions[c.Value]; a != nil { + return a + } + } + return s.Auth.Guest +} +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + json.NewEncoder(w).Encode(v) +} +func qpath(r *http.Request) string { + return strings.Trim(strings.ReplaceAll(r.URL.Query().Get("filepath"), "\\", "/"), "/") +} +func permString(p auth.PermMask) string { + if p.HasDelete() { + return "d" + } + if p.HasWrite() { + return "w" + } + if p.HasRead() { + return "r" + } + return "" +} +func (s *Server) files(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + p := qpath(r) + m := a.MaskFor(p) + if !m.HasRead() { + http.Error(w, "forbidden", 403) + return + } + list, e := s.Store.List(p) + if e != nil { + http.Error(w, e.Error(), 404) + return + } + out := make([]fileInfo, 0, len(list)) + for _, fi := range list { + fp := path.Join(p, fi.Name()) + sub := false + if fi.IsDir() { + if xs, e := s.Store.List(fp); e == nil { + sub = len(xs) > 0 + } + } + out = append(out, fileInfo{Name: fi.Name(), Path: fp, Size: fi.Size(), Modified: fi.ModTime().Format(time.RFC3339), Dir: fi.IsDir(), Mask: permString(a.MaskFor(fp)), HasSubDir: sub}) + } + writeJSON(w, map[string]any{"files": out, "currentMask": permString(m), "virtualRoot": false}) +} +func (s *Server) newdir(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + p := qpath(r) + if !a.MaskFor(p).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + name := r.FormValue("name") + if name == "" { + name = r.FormValue("new") + } + if name == "" { + http.Error(w, "name required", 400) + return + } + if e := s.Store.MkdirAll(path.Join(p, name)); e != nil { + http.Error(w, e.Error(), 500) + return + } + writeJSON(w, map[string]any{"ok": true}) +} +func (s *Server) rename(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + old := strings.Trim(r.FormValue("old"), "/") + neu := strings.Trim(r.FormValue("new"), "/") + if !a.MaskFor(old).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + if e := s.Store.Rename(old, neu); e != nil { + http.Error(w, e.Error(), 500) + return + } + writeJSON(w, map[string]any{"ok": true}) +} +func (s *Server) remove(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + _ = r.ParseForm() + items := r.Form["path"] + if len(items) == 0 { + items = r.Form["paths"] + } + for _, p := range items { + if !a.MaskFor(p).HasDelete() { + http.Error(w, "forbidden", 403) + return + } + if e := s.Store.Remove(p); e != nil { + http.Error(w, e.Error(), 500) + return + } + } + writeJSON(w, map[string]any{"ok": true}) +} +func (s *Server) exist(w http.ResponseWriter, r *http.Request) { + p := qpath(r) + _, e := s.Store.Stat(p) + writeJSON(w, map[string]any{"exist": e == nil}) +} +func (s *Server) session(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" { + a := account(r, s) + writeJSON(w, map[string]any{"login": a != nil, "user": a.Username}) + return + } + user, pwd := r.FormValue("user"), r.FormValue("pwd") + a := s.Auth.Authenticate(user, pwd) + if a == nil { + http.Error(w, "invalid account", 401) + return + } + token := auth.NewToken() + s.Sessions[token] = a + http.SetCookie(w, &http.Cookie{Name: "chfs_token", Value: token, Path: "/", HttpOnly: true, SameSite: http.SameSiteLaxMode}) + writeJSON(w, map[string]any{"ok": true, "user": a.Username}) +} +func (s *Server) newtext(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + p := strings.Trim(r.FormValue("path"), "/") + if !a.MaskFor(p).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + f, e := s.Store.Create(p) + if e != nil { + http.Error(w, e.Error(), 500) + return + } + defer f.Close() + io.WriteString(f, r.FormValue("text")) + writeJSON(w, map[string]any{"ok": true}) +} +func (s *Server) updatetext(w http.ResponseWriter, r *http.Request) { s.newtext(w, r) } +func (s *Server) upload(w http.ResponseWriter, r *http.Request) { + a := account(r, s) + dir := strings.Trim(r.FormValue("path"), "/") + if !a.MaskFor(dir).HasWrite() { + http.Error(w, "forbidden", 403) + return + } + if e := r.ParseMultipartForm(64 << 20); e != nil { + http.Error(w, e.Error(), 400) + return + } + var saved []string + for _, hs := range r.MultipartForm.File { + for _, h := range hs { + if e := savePart(s.Store, dir, h); e != nil { + http.Error(w, e.Error(), 500) + return + } + saved = append(saved, h.Filename) + } + } + writeJSON(w, map[string]any{"ok": true, "files": saved}) +} +func savePart(st storage.Storage, dir string, h *multipart.FileHeader) error { + in, e := h.Open() + if e != nil { + return e + } + defer in.Close() + f, e := st.Create(path.Join(dir, h.Filename)) + if e != nil { + return e + } + defer f.Close() + _, e = io.Copy(f, in) + return e +} diff --git a/chfs-ng/internal/auth/auth.go b/chfs-ng/internal/auth/auth.go index fba2da5..8c003ba 100644 --- a/chfs-ng/internal/auth/auth.go +++ b/chfs-ng/internal/auth/auth.go @@ -1,5 +1,12 @@ package auth +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "strings" +) + type PermMask uint8 const ( @@ -12,8 +19,116 @@ const ( func (p PermMask) HasRead() bool { return p&Read != 0 } func (p PermMask) HasWrite() bool { return p&Write != 0 } func (p PermMask) HasDelete() bool { return p&Delete != 0 } +func ParseMask(s string) PermMask { + var p PermMask + for _, r := range strings.ToLower(s) { + switch r { + case 'r': + p |= Read + case 'w': + p |= Read | Write + case 'd': + p |= Read | Write | Delete + } + } + return p +} +type PathRule struct { + Dir string + Mask PermMask +} type Account struct { Username, Password string Mask PermMask + Rules []PathRule +} +type Manager struct { + Accounts map[string]*Account + Guest *Account +} + +func ParseRules(values []string) (*Manager, error) { + m := &Manager{Accounts: map[string]*Account{}} + for _, value := range values { + for _, item := range strings.Split(value, "|") { + if item == "" { + continue + } + fields := strings.Split(item, ":") + if len(fields) < 3 || len(fields)%2 == 0 { + continue + } + a := &Account{Username: fields[0], Password: fields[1], Mask: ParseMask(fields[2])} + for i := 3; i+1 < len(fields); i += 2 { + a.Rules = append(a.Rules, PathRule{Dir: strings.Trim(fields[i], "/"), Mask: ParseMask(fields[i+1])}) + } + if a.Username == "" { + m.Guest = a + } else { + m.Accounts[a.Username] = a + } + } + } + if m.Guest == nil { + m.Guest = &Account{Mask: Read | Write} + } + return m, nil +} + +func (m *Manager) Authenticate(user, pwd string) *Account { + if a, ok := m.Accounts[user]; ok && a.Password == pwd { + return a + } + if user == "" && pwd == "" { + return m.Guest + } + return nil +} +func (a *Account) MaskFor(path string) PermMask { + path = strings.Trim(path, "/") + best := -1 + mask := a.Mask + for _, r := range a.Rules { + d := strings.Trim(r.Dir, "/") + if matchPath(d, path) && len(d) > best { + best = len(d) + mask = r.Mask + } + } + return mask +} +func matchPath(pattern, path string) bool { + if pattern == "" { + return path == "" + } + pp := strings.Split(pattern, "/") + ps := strings.Split(path, "/") + if len(ps) < len(pp) { + return false + } + for i, p := range pp { + if p == "*" { + return true + } + if p == "?" { + if ps[i] == "" { + return false + } + continue + } + if p != ps[i] { + return false + } + } + return true +} + +func NewToken() string { + b := make([]byte, 32) + if _, e := rand.Read(b); e != nil { + h := sha256.Sum256([]byte("fallback")) + return hex.EncodeToString(h[:]) + } + return hex.EncodeToString(b) } diff --git a/chfs-ng/internal/auth/auth_test.go b/chfs-ng/internal/auth/auth_test.go new file mode 100644 index 0000000..dd06895 --- /dev/null +++ b/chfs-ng/internal/auth/auth_test.go @@ -0,0 +1,20 @@ +package auth + +import "testing" + +func TestParseRules(t *testing.T) { + m, err := ParseRules([]string{"::r|admin:secret:rw:private:d"}) + if err != nil { + t.Fatal(err) + } + if !m.Guest.Mask.HasRead() || m.Guest.Mask.HasWrite() { + t.Fatal("guest mask") + } + a := m.Accounts["admin"] + if a == nil || !a.Mask.HasWrite() || a.Mask.HasDelete() { + t.Fatal("admin mask") + } + if got := a.MaskFor("private/x"); !got.HasDelete() { + t.Fatalf("subrule=%v", got) + } +} diff --git a/chfs-ng/internal/config/config.go b/chfs-ng/internal/config/config.go new file mode 100644 index 0000000..e88ab30 --- /dev/null +++ b/chfs-ng/internal/config/config.go @@ -0,0 +1,128 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "strconv" + "strings" +) + +type Config struct { + Path []string + Port int + Allow string + Rules []string + Log string + FileRemove int + ImagePreview bool + FolderDownload string + SessionTimeout int + HTMLTitle string + HTMLNotice string + SSLCert string + SSLKey string +} + +func Default() Config { + return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30} +} + +func Load(path string) (Config, error) { + c := Default() + f, err := os.Open(path) + if err != nil { + return c, err + } + defer f.Close() + s := bufio.NewScanner(f) + for s.Scan() { + line := strings.TrimSpace(s.Text()) + if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") { + continue + } + i := strings.IndexByte(line, '=') + if i < 0 { + continue + } + key, val := strings.TrimSpace(line[:i]), strings.TrimSpace(line[i+1:]) + val = strings.Trim(val, "\\\"") + switch strings.ToLower(key) { + case "path": + c.Path = splitPath(val) + case "port": + if n, e := strconv.Atoi(val); e == nil && n > 0 && n < 65536 { + c.Port = n + } + case "allow": + c.Allow = val + case "rule": + if val != "" { + c.Rules = append(c.Rules, val) + } + case "log": + c.Log = val + case "file.remove": + if n, e := strconv.Atoi(val); e == nil { + c.FileRemove = n + } + case "image.preview": + c.ImagePreview = parseBool(val, c.ImagePreview) + case "folder.download": + c.FolderDownload = strings.ToLower(val) + case "folder.leaf.download": + if parseBool(val, false) { + c.FolderDownload = "leaf" + } + case "session.timeout": + if n, e := strconv.Atoi(val); e == nil && n > 0 { + c.SessionTimeout = n + } + case "html.title": + c.HTMLTitle = val + case "html.notice": + c.HTMLNotice = val + case "ssl.cert": + c.SSLCert = val + case "ssl.key": + c.SSLKey = val + } + } + if err := s.Err(); err != nil { + return c, err + } + return c, nil +} + +func splitPath(v string) []string { + var out []string + for _, p := range strings.Split(v, "|") { + p = strings.TrimSpace(strings.Trim(p, "\\\"")) + if p != "" { + out = append(out, p) + } + } + if len(out) == 0 { + return []string{"."} + } + return out +} + +func parseBool(v string, d bool) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "true", "1", "yes", "on": + return true + case "false", "0", "no", "off": + return false + } + return d +} +func (c Config) Validate() error { + if len(c.Path) == 0 { + return fmt.Errorf("path is empty") + } + if c.Port < 1 || c.Port > 65535 { + return fmt.Errorf("invalid port") + } + return nil +} diff --git a/chfs-ng/internal/storage/storage.go b/chfs-ng/internal/storage/storage.go index 30d3cf5..b018af7 100644 --- a/chfs-ng/internal/storage/storage.go +++ b/chfs-ng/internal/storage/storage.go @@ -1,8 +1,11 @@ package storage import ( + "errors" + "io/fs" "os" "path/filepath" + "strings" ) type Storage interface { @@ -17,24 +20,82 @@ type Storage interface { type Local struct{ Root string } -func (s Local) real(path string) string { return filepath.Join(s.Root, filepath.Clean("/"+path)) } -func (s Local) Stat(path string) (os.FileInfo, error) { return os.Stat(s.real(path)) } -func (s Local) Open(path string) (*os.File, error) { return os.Open(s.real(path)) } +func (s Local) real(path string) (string, error) { + root, err := filepath.Abs(s.Root) + if err != nil { + return "", err + } + p := strings.TrimPrefix(filepath.Clean("/"+path), string(filepath.Separator)) + full := filepath.Join(root, p) + rel, err := filepath.Rel(root, full) + if err != nil { + return "", err + } + if rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New("path escapes storage root") + } + return full, nil +} +func (s Local) Stat(path string) (os.FileInfo, error) { + p, e := s.real(path) + if e != nil { + return nil, e + } + return os.Stat(p) +} +func (s Local) Open(path string) (*os.File, error) { + p, e := s.real(path) + if e != nil { + return nil, e + } + return os.Open(p) +} func (s Local) Create(path string) (*os.File, error) { - p := s.real(path) - if err := os.MkdirAll(filepath.Dir(p), 0755); err != nil { - return nil, err + p, e := s.real(path) + if e != nil { + return nil, e + } + if e = os.MkdirAll(filepath.Dir(p), 0755); e != nil { + return nil, e } return os.Create(p) } -func (s Local) Remove(path string) error { return os.RemoveAll(s.real(path)) } -func (s Local) Rename(a, b string) error { return os.Rename(s.real(a), s.real(b)) } -func (s Local) MkdirAll(path string) error { return os.MkdirAll(s.real(path), 0755) } +func (s Local) Remove(path string) error { + p, e := s.real(path) + if e != nil { + return e + } + return os.RemoveAll(p) +} +func (s Local) Rename(a, b string) error { + pa, e := s.real(a) + if e != nil { + return e + } + pb, e := s.real(b) + if e != nil { + return e + } + return os.Rename(pa, pb) +} +func (s Local) MkdirAll(path string) error { + p, e := s.real(path) + if e != nil { + return e + } + return os.MkdirAll(p, 0755) +} func (s Local) List(path string) ([]os.FileInfo, error) { - f, err := os.Open(s.real(path)) - if err != nil { - return nil, err + p, e := s.real(path) + if e != nil { + return nil, e + } + f, e := os.Open(p) + if e != nil { + return nil, e } defer f.Close() return f.Readdir(-1) } + +var _ fs.FS