From 77572e610bf1d1d1fc3c4f38fe0f0914684217f5 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 01:14:15 +0800 Subject: [PATCH] feat: add web ui and dynamic private users --- chfs-ng/cmd/chfs/main.go | 38 +++++- chfs-ng/internal/api/server.go | 92 +++++++++++++- chfs-ng/internal/api/user_management_test.go | 87 ++++++++++++++ chfs-ng/internal/api/web/index.html | 66 ++++++++++ chfs-ng/internal/auth/auth.go | 12 +- chfs-ng/internal/auth/users.go | 119 +++++++++++++++++++ chfs-ng/internal/config/config.go | 11 +- 7 files changed, 418 insertions(+), 7 deletions(-) create mode 100644 chfs-ng/internal/api/user_management_test.go create mode 100644 chfs-ng/internal/api/web/index.html create mode 100644 chfs-ng/internal/auth/users.go diff --git a/chfs-ng/cmd/chfs/main.go b/chfs-ng/cmd/chfs/main.go index 0a9cc48..0718976 100644 --- a/chfs-ng/cmd/chfs/main.go +++ b/chfs-ng/cmd/chfs/main.go @@ -65,7 +65,12 @@ func main() { log.Fatal(err) } for _, ac := range cfg.Accounts { - a := &auth.Account{Username: ac.Username, Password: ac.Password, Mask: auth.ParseMask(ac.Default)} + admin := ac.Role == "admin" || strings.EqualFold(ac.Username, "admin") + mask := auth.ParseMask(ac.Default) + if admin && ac.Default == "" { + mask = auth.Read | auth.Write | auth.Delete + } + a := &auth.Account{Username: ac.Username, Password: ac.Password, Mask: mask, Admin: admin} for _, d := range ac.None { a.Rules = append(a.Rules, auth.PathRule{Dir: accountRulePath(d, cfg.Path), Deny: true}) } @@ -84,12 +89,41 @@ func main() { mgr.Accounts[ac.Username] = a } } + if cfg.UserHome == "" { + cfg.UserHome = "users" + } multi := storage.NewMulti(cfg.Path) + if len(cfg.Path) > 1 { + if _, _, ok := multi.FindRoot(cfg.UserHome); !ok { + cfg.UserHome = multi.Names[0] + "/" + strings.Trim(cfg.UserHome, "/") + } + } store := storage.Storage(multi) if len(cfg.Path) == 1 { store = storage.Local{Root: root} } - srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, WebDAVRoot: root, Allow: cfg.Allow, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log)} + userFile := cfg.UserFile + if userFile == "" { + if dir, e := os.UserConfigDir(); e == nil { + userFile = filepath.Join(dir, "chfs-ng", "users.json") + } else { + userFile = filepath.Join(root, ".chfs-users.json") + } + } else if !filepath.IsAbs(userFile) { + userFile = filepath.Join(root, userFile) + } + users := &auth.UserStore{Path: userFile} + if err := users.Load(mgr, cfg.UserHome); err != nil { + log.Fatal(err) + } + for _, a := range mgr.Accounts { + if a.Home != "" { + if err := store.MkdirAll(a.Home); err != nil { + log.Fatal(err) + } + } + } + srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, WebDAVRoot: root, Allow: cfg.Allow, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome} addr := ":" + itoa(cfg.Port) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) if cfg.SSLCert != "" && cfg.SSLKey != "" { diff --git a/chfs-ng/internal/api/server.go b/chfs-ng/internal/api/server.go index 794845d..12a776c 100644 --- a/chfs-ng/internal/api/server.go +++ b/chfs-ng/internal/api/server.go @@ -4,6 +4,7 @@ import ( "archive/zip" "bytes" "context" + _ "embed" "encoding/json" "fmt" "html" @@ -30,6 +31,9 @@ import ( "github.com/emersion/go-webdav" ) +//go:embed web/index.html +var webIndex []byte + type Server struct { Store storage.Storage Auth *auth.Manager @@ -47,6 +51,8 @@ type Server struct { HTMLTitle string HTMLNotice string Log *opslog.Logger + UserStore *auth.UserStore + UserHome string } type fileInfo struct { Name string `json:"name"` @@ -69,6 +75,8 @@ 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/me", s.me) + mux.HandleFunc("/chfs/users", s.users) mux.HandleFunc("/chfs/newtext", s.newtext) mux.HandleFunc("/chfs/updatetext", s.updatetext) mux.HandleFunc("/chfs/upload", s.upload) @@ -450,6 +458,82 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) { 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))}) +} + +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 != ""}) + } + 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.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") @@ -737,7 +821,7 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) { return } p := strings.Trim(strings.TrimPrefix(r.URL.Path, "/chfs/shared/"), "/") - if unsafePath(p) { + if unsafePath(p) || !account(r, s).MaskFor(p).HasRead() { http.NotFound(w, r) return } @@ -842,8 +926,10 @@ func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) { } 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") - _, _ = fmt.Fprintf(w, `%s

%s

%s

Open file API · API documentation

`, html.EscapeString(title), html.EscapeString(title), notice) + _, _ = w.Write(page) } func (s *Server) apiDoc(w http.ResponseWriter, r *http.Request) { @@ -852,7 +938,7 @@ func (s *Server) apiDoc(w http.ResponseWriter, r *http.Request) { return } w.Header().Set("Content-Type", "text/html; charset=utf-8") - const doc = `CHFS API

CHFS API

Compatibility API implemented by chfs-ng.

` + const doc = `CHFS API

CHFS API

Compatibility API implemented by chfs-ng.

` _, _ = w.Write([]byte(doc)) } diff --git a/chfs-ng/internal/api/user_management_test.go b/chfs-ng/internal/api/user_management_test.go new file mode 100644 index 0000000..9565535 --- /dev/null +++ b/chfs-ng/internal/api/user_management_test.go @@ -0,0 +1,87 @@ +package api + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "chfs-ng/internal/auth" + "chfs-ng/internal/storage" +) + +func TestUserManagementAndPrivateHome(t *testing.T) { + root := t.TempDir() + usersFile := filepath.Join(t.TempDir(), "users.json") + mgr := &auth.Manager{ + Accounts: map[string]*auth.Account{ + "admin": {Username: "admin", Password: "secret", Admin: true, Mask: auth.Read | auth.Write | auth.Delete}, + }, + Guest: &auth.Account{Mask: auth.None}, + } + s := &Server{Store: storage.Local{Root: root}, Auth: mgr, UserStore: &auth.UserStore{Path: usersFile}, UserHome: "users"} + h := s.Handler() + + req := httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=admin&pwd=secret")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("admin login: %d %s", rr.Code, rr.Body.String()) + } + cookie := rr.Result().Cookies()[0] + + body := []byte(`{"username":"alice","password":"alice-pass"}`) + req = httptest.NewRequest(http.MethodPost, "/chfs/users", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.AddCookie(cookie) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("create user: %d %s", rr.Code, rr.Body.String()) + } + if _, err := os.Stat(filepath.Join(root, "users", "alice")); err != nil { + t.Fatalf("home not created: %v", err) + } + + req = httptest.NewRequest(http.MethodPost, "/chfs/session", strings.NewReader("user=alice&pwd=alice-pass")) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusCreated { + t.Fatalf("dynamic user login: %d %s", rr.Code, rr.Body.String()) + } + + token, err := auth.NewToken("alice", 30) + if err != nil { + t.Fatal(err) + } + req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=", nil) + req.AddCookie(&http.Cookie{Name: "JWT", Value: token}) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "currentMask") || !strings.Contains(rr.Body.String(), "\"currentMask\":\"\"") { + t.Fatalf("private root access: %d %s", rr.Code, rr.Body.String()) + } + + req = httptest.NewRequest(http.MethodGet, "/chfs/files?filepath=users/alice", nil) + req.AddCookie(&http.Cookie{Name: "JWT", Value: token}) + rr = httptest.NewRecorder() + h.ServeHTTP(rr, req) + if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"RWD\"") { + t.Fatalf("private home access: %d %s", rr.Code, rr.Body.String()) + } + + var saved []map[string]any + data, err := os.ReadFile(usersFile) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(data, &saved); err != nil || len(saved) != 1 { + t.Fatalf("user persistence: %v %#v", err, saved) + } +} diff --git a/chfs-ng/internal/api/web/index.html b/chfs-ng/internal/api/web/index.html new file mode 100644 index 0000000..61f27d5 --- /dev/null +++ b/chfs-ng/internal/api/web/index.html @@ -0,0 +1,66 @@ + + + + + +__CHFS_TITLE__ + + + +
+
CHFS
/
+ + +
+
+
__CHFS_NOTICE__
+
+ + + + + +
+
名称大小修改时间
+
+ + + diff --git a/chfs-ng/internal/auth/auth.go b/chfs-ng/internal/auth/auth.go index 85eeb31..913632a 100644 --- a/chfs-ng/internal/auth/auth.go +++ b/chfs-ng/internal/auth/auth.go @@ -1,6 +1,8 @@ package auth import ( + "crypto/sha256" + "encoding/hex" "fmt" "strings" "time" @@ -43,8 +45,11 @@ type PathRule struct { } type Account struct { Username, Password string + PasswordHash string Mask PermMask Rules []PathRule + Home string + Admin bool } type Manager struct { Accounts map[string]*Account @@ -80,7 +85,7 @@ func ParseRules(values []string) (*Manager, error) { } func (m *Manager) Authenticate(user, pwd string) *Account { - if a, ok := m.Accounts[user]; ok && a.Password == pwd { + if a, ok := m.Accounts[user]; ok && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) { return a } if user == "" && pwd == "" { @@ -88,6 +93,11 @@ func (m *Manager) Authenticate(user, pwd string) *Account { } return nil } +func PasswordHash(password string) string { + h := sha256.Sum256([]byte(password)) + return hex.EncodeToString(h[:]) +} + func (a *Account) MaskFor(path string) PermMask { return a.maskFor(path, false) } diff --git a/chfs-ng/internal/auth/users.go b/chfs-ng/internal/auth/users.go new file mode 100644 index 0000000..5701564 --- /dev/null +++ b/chfs-ng/internal/auth/users.go @@ -0,0 +1,119 @@ +package auth + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync" +) + +type UserStore struct { + Path string + Mu sync.Mutex +} + +type userRecord struct { + Username string `json:"username"` + Password string `json:"passwordHash"` + Home string `json:"home"` +} + +var validUsername = regexp.MustCompile("^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$") + +func ValidateUsername(username string) error { + if !validUsername.MatchString(username) { + return fmt.Errorf("invalid username") + } + return nil +} + +func (u *UserStore) Load(m *Manager, homeRoot string) error { + if u.Path == "" { + return nil + } + data, err := os.ReadFile(u.Path) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + var records []userRecord + if err := json.Unmarshal(data, &records); err != nil { + return fmt.Errorf("invalid user store: %w", err) + } + for _, rec := range records { + if err := ValidateUsername(rec.Username); err != nil || rec.Password == "" { + continue + } + if _, exists := m.Accounts[rec.Username]; exists { + continue + } + home := filepath.ToSlash(strings.Trim(strings.TrimSpace(rec.Home), "/")) + if home == "" { + home = filepath.ToSlash(filepath.Join(homeRoot, rec.Username)) + } + a := &Account{Username: rec.Username, PasswordHash: rec.Password, Mask: None, Home: home} + a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete}) + m.Accounts[rec.Username] = a + } + return nil +} + +func (u *UserStore) Save(m *Manager) error { + if u.Path == "" { + return nil + } + u.Mu.Lock() + defer u.Mu.Unlock() + records := make([]userRecord, 0) + for _, a := range m.Accounts { + if a.PasswordHash == "" || a.Home == "" || a.Admin { + continue + } + records = append(records, userRecord{Username: a.Username, Password: a.PasswordHash, Home: a.Home}) + } + data, err := json.MarshalIndent(records, "", " ") + if err != nil { + return err + } + if dir := filepath.Dir(u.Path); dir != "." { + if err := os.MkdirAll(dir, 0700); err != nil { + return err + } + } + tmp := u.Path + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + return os.Rename(tmp, u.Path) +} + +func (u *UserStore) Add(m *Manager, username, password, home string) (*Account, error) { + if err := ValidateUsername(username); err != nil { + return nil, err + } + if strings.TrimSpace(password) == "" { + return nil, fmt.Errorf("password required") + } + if _, ok := m.Accounts[username]; ok { + return nil, fmt.Errorf("user already exists") + } + home = strings.Trim(home, "/") + a := &Account{Username: username, PasswordHash: PasswordHash(password), Mask: None, Home: home} + a.Rules = append(a.Rules, PathRule{Dir: home, Mask: Read | Write | Delete}) + m.Accounts[username] = a + return a, u.Save(m) +} + +func (u *UserStore) Delete(m *Manager, username string) error { + a, ok := m.Accounts[username] + if !ok || a.Admin || a.PasswordHash == "" { + return fmt.Errorf("managed user not found") + } + delete(m.Accounts, username) + return u.Save(m) +} diff --git a/chfs-ng/internal/config/config.go b/chfs-ng/internal/config/config.go index 5868be8..85bce64 100644 --- a/chfs-ng/internal/config/config.go +++ b/chfs-ng/internal/config/config.go @@ -11,6 +11,7 @@ import ( type AccountConfig struct { Username string Password string + Role string Default string None []string Read []string @@ -30,13 +31,15 @@ type Config struct { SessionTimeout int HTMLTitle string HTMLNotice string + UserFile string + UserHome string SSLCert string SSLKey string Accounts []AccountConfig } func Default() Config { - return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30} + return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users"} } func Load(path string) (Config, error) { @@ -73,6 +76,8 @@ func Load(path string) (Config, error) { switch strings.ToLower(key) { case "password": section.Password = val + case "role": + section.Role = strings.ToLower(val) case "rule.default": section.Default = val case "rule.none": @@ -121,6 +126,10 @@ func Load(path string) (Config, error) { c.HTMLTitle = val case "html.notice": c.HTMLNotice = val + case "user.file": + c.UserFile = val + case "user.home": + c.UserHome = strings.Trim(val, "/") case "ssl.cert": c.SSLCert = val case "ssl.key":