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
%sOpen 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 APICHFS API
Compatibility API implemented by chfs-ng.
- /chfs/files
- /chfs/session
- /chfs/upload
- /chfs/newdir
- /chfs/rename
- /chfs/rmfiles
- /chfs/search
- /chfs/downloaddir/<path>
- /chfs/shared/<path>
- /chfs/preview
- /webdav
`
+ const doc = `CHFS APICHFS API
Compatibility API implemented by chfs-ng.
- /chfs/files
- /chfs/session
- /chfs/me
- /chfs/users (admin)
- /chfs/upload
- /chfs/newdir
- /chfs/rename
- /chfs/rmfiles
- /chfs/search
- /chfs/downloaddir/<path>
- /chfs/shared/<path>
- /chfs/preview
- /webdav
`
_, _ = 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_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":