feat: add web ui and dynamic private users

This commit is contained in:
OpenClaw Agent
2026-09-22 01:14:15 +08:00
parent 035c3600bf
commit 77572e610b
7 changed files with 418 additions and 7 deletions
+89 -3
View File
@@ -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, `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>%s</title></head><body><main><h1>%s</h1>%s<p><a href="/chfs/files?filepath=">Open file API</a> · <a href="/asset/api.html">API documentation</a></p></main></body></html>`, 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 = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CHFS API</title></head><body><h1>CHFS API</h1><p>Compatibility API implemented by chfs-ng.</p><ul><li>/chfs/files</li><li>/chfs/session</li><li>/chfs/upload</li><li>/chfs/newdir</li><li>/chfs/rename</li><li>/chfs/rmfiles</li><li>/chfs/search</li><li>/chfs/downloaddir/&lt;path&gt;</li><li>/chfs/shared/&lt;path&gt;</li><li>/chfs/preview</li><li>/webdav</li></ul></body></html>`
const doc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>CHFS API</title></head><body><h1>CHFS API</h1><p>Compatibility API implemented by chfs-ng.</p><ul><li>/chfs/files</li><li>/chfs/session</li><li>/chfs/me</li><li>/chfs/users (admin)</li><li>/chfs/upload</li><li>/chfs/newdir</li><li>/chfs/rename</li><li>/chfs/rmfiles</li><li>/chfs/search</li><li>/chfs/downloaddir/&lt;path&gt;</li><li>/chfs/shared/&lt;path&gt;</li><li>/chfs/preview</li><li>/webdav</li></ul></body></html>`
_, _ = w.Write([]byte(doc))
}