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
+36 -2
View File
@@ -65,7 +65,12 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
for _, ac := range cfg.Accounts { 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 { for _, d := range ac.None {
a.Rules = append(a.Rules, auth.PathRule{Dir: accountRulePath(d, cfg.Path), Deny: true}) 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 mgr.Accounts[ac.Username] = a
} }
} }
if cfg.UserHome == "" {
cfg.UserHome = "users"
}
multi := storage.NewMulti(cfg.Path) 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) store := storage.Storage(multi)
if len(cfg.Path) == 1 { if len(cfg.Path) == 1 {
store = storage.Local{Root: root} 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) addr := ":" + itoa(cfg.Port)
log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove)
if cfg.SSLCert != "" && cfg.SSLKey != "" { if cfg.SSLCert != "" && cfg.SSLKey != "" {
+89 -3
View File
@@ -4,6 +4,7 @@ import (
"archive/zip" "archive/zip"
"bytes" "bytes"
"context" "context"
_ "embed"
"encoding/json" "encoding/json"
"fmt" "fmt"
"html" "html"
@@ -30,6 +31,9 @@ import (
"github.com/emersion/go-webdav" "github.com/emersion/go-webdav"
) )
//go:embed web/index.html
var webIndex []byte
type Server struct { type Server struct {
Store storage.Storage Store storage.Storage
Auth *auth.Manager Auth *auth.Manager
@@ -47,6 +51,8 @@ type Server struct {
HTMLTitle string HTMLTitle string
HTMLNotice string HTMLNotice string
Log *opslog.Logger Log *opslog.Logger
UserStore *auth.UserStore
UserHome string
} }
type fileInfo struct { type fileInfo struct {
Name string `json:"name"` Name string `json:"name"`
@@ -69,6 +75,8 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/chfs/rmfiles", s.remove) mux.HandleFunc("/chfs/rmfiles", s.remove)
mux.HandleFunc("/chfs/exist", s.exist) mux.HandleFunc("/chfs/exist", s.exist)
mux.HandleFunc("/chfs/session", s.session) 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/newtext", s.newtext)
mux.HandleFunc("/chfs/updatetext", s.updatetext) mux.HandleFunc("/chfs/updatetext", s.updatetext)
mux.HandleFunc("/chfs/upload", s.upload) mux.HandleFunc("/chfs/upload", s.upload)
@@ -450,6 +458,82 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated) 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) { func (s *Server) newtext(w http.ResponseWriter, r *http.Request) {
a := account(r, s) a := account(r, s)
rawDir, rawTitle, rawFilename := r.FormValue("filepath"), r.FormValue("title"), r.FormValue("filename") 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 return
} }
p := strings.Trim(strings.TrimPrefix(r.URL.Path, "/chfs/shared/"), "/") 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) http.NotFound(w, r)
return return
} }
@@ -842,8 +926,10 @@ func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
} else { } else {
notice = html.EscapeString(notice) 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") 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) { 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 return
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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)) _, _ = w.Write([]byte(doc))
} }
@@ -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)
}
}
+66
View File
@@ -0,0 +1,66 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>__CHFS_TITLE__</title>
<style>
:root{font-family:Inter,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:#172033;background:#f5f7fb}
*{box-sizing:border-box}body{margin:0}.top{height:64px;background:#fff;border-bottom:1px solid #e5e9f2;display:flex;align-items:center;padding:0 22px;gap:18px;position:sticky;top:0;z-index:2}.brand{font-weight:750;font-size:20px}.path{color:#718096;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.spacer{flex:1}
button,input{font:inherit}button{border:0;border-radius:9px;padding:9px 13px;cursor:pointer;background:#2563eb;color:#fff}button.ghost{background:#eef2f7;color:#334155}button.danger{background:#dc2626}
main{max-width:1180px;margin:26px auto;padding:0 18px}.toolbar{display:flex;gap:9px;flex-wrap:wrap;margin-bottom:18px}.toolbar input{min-width:220px;flex:1;padding:10px 12px;border:1px solid #d9dfeb;border-radius:9px;background:#fff}
.card{background:#fff;border:1px solid #e4e8f0;border-radius:14px;overflow:hidden;box-shadow:0 4px 18px rgba(20,30,50,.04)}
table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 15px;border-bottom:1px solid #edf0f5}th{font-size:13px;color:#7b8798;background:#fafbfc}tr:last-child td{border-bottom:0}.name{display:flex;align-items:center;gap:10px}.name a{color:#172033;text-decoration:none;font-weight:550}.meta{color:#8792a5;font-size:13px}.actions{display:flex;gap:7px;justify-content:flex-end}.empty{padding:45px;text-align:center;color:#8a94a6}
.modal{position:fixed;inset:0;background:rgba(15,23,42,.4);display:none;align-items:center;justify-content:center;padding:18px;z-index:4}.modal.show{display:flex}.dialog{width:min(480px,100%);background:#fff;border-radius:15px;padding:22px}.dialog h2{margin-top:0}.field{display:grid;gap:7px;margin:13px 0}.field input{padding:10px 11px;border:1px solid #d7ddea;border-radius:9px}.row{display:flex;gap:9px;justify-content:flex-end;margin-top:18px}
.login{max-width:390px;margin:10vh auto}.notice{padding:12px 14px;background:#eff6ff;border-radius:10px;color:#315b9b;margin-bottom:14px}
@media(max-width:700px){th:nth-child(2),td:nth-child(2),th:nth-child(3),td:nth-child(3){display:none}.top{padding:0 13px}.path{max-width:40vw}.actions button{padding:7px 9px}.toolbar input{min-width:160px}}
</style>
</head>
<body>
<header class="top">
<div class="brand" id="brand">CHFS</div><div class="path" id="crumb">/</div><div class="spacer"></div>
<span id="who" class="meta"></span><button class="ghost" id="loginBtn" onclick="openLogin()">登录</button>
<button class="ghost" id="logoutBtn" onclick="logout()" hidden>退出</button>
</header>
<main>
<div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div>
<div class="toolbar">
<button onclick="goUp()">上一级</button><button onclick="refresh()">刷新</button>
<button onclick="newFolder()">新建文件夹</button><button onclick="newText()">新建文本</button>
<label><button>上传</button><input id="upload" type="file" multiple hidden></label>
<input id="search" placeholder="搜索文件名,回车执行">
<button id="usersBtn" onclick="showUsers()" hidden>用户管理</button>
</div>
<div class="card"><table><thead><tr><th>名称</th><th>大小</th><th>修改时间</th><th></th></tr></thead><tbody id="files"></tbody></table></div>
</main>
<div class="modal" id="modal"><div class="dialog" id="dialog"></div></div>
<script>
const $=id=>document.getElementById(id);let current="",me=null;
async function api(url,opt={}){const r=await fetch(url,opt);if(!r.ok){let t="请求失败";try{t=(await r.json()).error||t}catch{};throw Error(t)}return r.status===204?null:r.json()}
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;"}[c]))}
function fmt(n){if(n<1024)return n+" B";if(n<1048576)return (n/1024).toFixed(1)+" KB";if(n<1073741824)return (n/1048576).toFixed(1)+" MB";return (n/1073741824).toFixed(1)+" GB"}
async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:"",admin:false}};updateUser();current=me.home||"";await refresh()}
function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin}
async function refresh(){try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);render(data.files||[]);$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}}
function render(items){const body=$("files");body.innerHTML="";if(!items.length){body.innerHTML='<tr><td colspan="4" class="empty">这里还没有文件</td></tr>';return}for(const f of items){const p=current?current+"/"+f.name:f.name;const row=document.createElement("tr");const icon=f.dir?"📁":"📄";row.innerHTML='<td><div class="name"><span>'+icon+'</span>'+ (f.dir?'<a href="#" onclick="openDir('+JSON.stringify(p)+');return false">'+esc(f.name)+'</a>':'<span>'+esc(f.name)+'</span>')+'</div></td><td class="meta">'+(f.dir?"—":fmt(f.size))+'</td><td class="meta">'+esc(f.modified||"")+'</td><td><div class="actions">'+(f.dir?'<button class="ghost" onclick="downloadDir('+JSON.stringify(p)+')">ZIP</button>':'<button class="ghost" onclick="downloadFile('+JSON.stringify(p)+')">下载</button>')+'<button class="ghost" onclick="renameItem('+JSON.stringify(p)+')">改名</button><button class="danger" onclick="removeItem('+JSON.stringify(p)+')">删除</button></div></td>';body.appendChild(row)}}
function openDir(p){current=p;refresh()}function goUp(){if(!current)return;const root=me.home||"";if(current===root)return;let p=current.split("/");p.pop();let n=p.join("/");if(root&&!(n===root||n.startsWith(root+"/")))n=root;current=n;refresh()}
function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")}
function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)}
async function renameItem(p){const name=p.split("/").pop();const n=prompt("新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});refresh()}catch(e){alert(e.message)}}
async function removeItem(p){if(!confirm("确定删除 "+p+"?"))return;try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){alert(e.message)}}
async function newFolder(){const n=prompt("文件夹名称");if(!n)return;const p=current?current+"/"+n:n;try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});refresh()}catch(e){alert(e.message)}}
async function newText(){const n=prompt("文件名","new.txt");if(!n)return;const text=prompt("文本内容","");try{await api("/chfs/newtext",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:current,title:n,text:text||""})});refresh()}catch(e){alert(e.message)}}
$("upload").addEventListener("change",async e=>{const fd=new FormData();fd.append("folder",current);for(const f of e.target.files)fd.append("file",f);try{await api("/chfs/upload",{method:"POST",body:fd});refresh()}catch(x){alert(x.message)}e.target.value=""})
$("search").addEventListener("keydown",async e=>{if(e.key!=="Enter")return;const s=e.target.value.trim();if(!s){refresh();return}try{const d=await api("/chfs/search?str="+encodeURIComponent(s));renderSearch(d.files||[])}catch(x){alert(x.message)}})
function renderSearch(items){$("crumb").textContent="搜索结果";const body=$("files");body.innerHTML="";for(const f of items){const row=document.createElement("tr");row.innerHTML='<td><div class="name">🔎 <a href="#" onclick="openDir('+JSON.stringify(f.dir?f.path:f.path.split("/").slice(0,-1).join("/")).replace(/^"/,'\\"')+');return false">'+esc(f.path)+'</a></div></td><td class="meta">'+(f.dir?"—":fmt(f.size))+'</td><td class="meta">'+esc(f.modified||"")+'</td><td></td>';body.appendChild(row)}}
function openLogin(){show('<h2>登录</h2><div class="field"><label>用户名</label><input id="u" autocomplete="username"></div><div class="field"><label>密码</label><input id="p" type="password" autocomplete="current-password"></div><div class="row"><button class="ghost" onclick="closeModal()">取消</button><button onclick="login()">登录</button></div>')}
async function login(){try{await fetch("/chfs/session",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({user:$("u").value,pwd:$("p").value})}).then(async r=>{if(!r.ok)throw Error((await r.json()).error||"登录失败")});closeModal();await init()}catch(e){alert(e.message)}}
async function logout(){await fetch("/chfs/session",{method:"DELETE"});location.reload()}
function show(html){$("dialog").innerHTML=html;$("modal").classList.add("show")}function closeModal(){$("modal").classList.remove("show")}
$("modal").addEventListener("click",e=>{if(e.target.id==="modal")closeModal()})
async function showUsers(){try{const d=await api("/chfs/users");let html='<h2>用户管理</h2><div class="field"><input id="nu" placeholder="用户名"><input id="np" type="password" placeholder="初始密码"><button onclick="createUser()">创建用户</button></div><div>';for(const u of d.users)html+='<p><b>'+esc(u.username)+'</b> · '+esc(u.home||"")+(u.managed&&!u.admin?' <button class="danger" onclick="deleteUser('+JSON.stringify(u.username)+')">删除</button>':"")+"</p>";html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){alert(e.message)}}
async function createUser(){try{await api("/chfs/users",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({username:$("nu").value,password:$("np").value})});showUsers()}catch(e){alert(e.message)}}
async function deleteUser(u){if(!confirm("删除用户 "+u+"?用户目录不会自动删除。"))return;try{await api("/chfs/users?username="+encodeURIComponent(u),{method:"DELETE"});showUsers()}catch(e){alert(e.message)}}
init();
</script>
</body></html>
+11 -1
View File
@@ -1,6 +1,8 @@
package auth package auth
import ( import (
"crypto/sha256"
"encoding/hex"
"fmt" "fmt"
"strings" "strings"
"time" "time"
@@ -43,8 +45,11 @@ type PathRule struct {
} }
type Account struct { type Account struct {
Username, Password string Username, Password string
PasswordHash string
Mask PermMask Mask PermMask
Rules []PathRule Rules []PathRule
Home string
Admin bool
} }
type Manager struct { type Manager struct {
Accounts map[string]*Account Accounts map[string]*Account
@@ -80,7 +85,7 @@ func ParseRules(values []string) (*Manager, error) {
} }
func (m *Manager) Authenticate(user, pwd string) *Account { 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 return a
} }
if user == "" && pwd == "" { if user == "" && pwd == "" {
@@ -88,6 +93,11 @@ func (m *Manager) Authenticate(user, pwd string) *Account {
} }
return nil return nil
} }
func PasswordHash(password string) string {
h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:])
}
func (a *Account) MaskFor(path string) PermMask { func (a *Account) MaskFor(path string) PermMask {
return a.maskFor(path, false) return a.maskFor(path, false)
} }
+119
View File
@@ -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)
}
+10 -1
View File
@@ -11,6 +11,7 @@ import (
type AccountConfig struct { type AccountConfig struct {
Username string Username string
Password string Password string
Role string
Default string Default string
None []string None []string
Read []string Read []string
@@ -30,13 +31,15 @@ type Config struct {
SessionTimeout int SessionTimeout int
HTMLTitle string HTMLTitle string
HTMLNotice string HTMLNotice string
UserFile string
UserHome string
SSLCert string SSLCert string
SSLKey string SSLKey string
Accounts []AccountConfig Accounts []AccountConfig
} }
func Default() Config { 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) { func Load(path string) (Config, error) {
@@ -73,6 +76,8 @@ func Load(path string) (Config, error) {
switch strings.ToLower(key) { switch strings.ToLower(key) {
case "password": case "password":
section.Password = val section.Password = val
case "role":
section.Role = strings.ToLower(val)
case "rule.default": case "rule.default":
section.Default = val section.Default = val
case "rule.none": case "rule.none":
@@ -121,6 +126,10 @@ func Load(path string) (Config, error) {
c.HTMLTitle = val c.HTMLTitle = val
case "html.notice": case "html.notice":
c.HTMLNotice = val c.HTMLNotice = val
case "user.file":
c.UserFile = val
case "user.home":
c.UserHome = strings.Trim(val, "/")
case "ssl.cert": case "ssl.cert":
c.SSLCert = val c.SSLCert = val
case "ssl.key": case "ssl.key":