feat: expose accessible storage locations

This commit is contained in:
OpenClaw Agent
2026-09-22 02:05:06 +08:00
parent d738fa41a3
commit 901321e329
3 changed files with 50 additions and 1 deletions
+40
View File
@@ -76,6 +76,7 @@ func (s *Server) Handler() http.Handler {
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/me", s.me)
mux.HandleFunc("/chfs/roots", s.roots)
mux.HandleFunc("/chfs/users", s.users) mux.HandleFunc("/chfs/users", s.users)
mux.HandleFunc("/chfs/password", s.password) mux.HandleFunc("/chfs/password", s.password)
mux.HandleFunc("/chfs/newtext", s.newtext) mux.HandleFunc("/chfs/newtext", s.newtext)
@@ -468,6 +469,45 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"username": a.Username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home)), "managed": a.PasswordHash != "", "enabled": a.Enabled}) writeJSON(w, map[string]any{"username": a.Username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home)), "managed": a.PasswordHash != "", "enabled": a.Enabled})
} }
type accessibleRoot struct {
Path string `json:"path"`
Name string `json:"name"`
Mask string `json:"mask"`
Home bool `json:"home"`
}
func (s *Server) roots(w http.ResponseWriter, r *http.Request) {
a := account(r, s)
if a == nil || a.Username == "" {
writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return
}
seen := map[string]bool{}
out := make([]accessibleRoot, 0)
if a.Admin && a.MaskFor("").HasRead() {
seen[""] = true
out = append(out, accessibleRoot{Path: "", Name: "全部文件", Mask: permString(a.MaskFor("")), Home: false})
}
if a.Home != "" && a.MaskFor(a.Home).HasRead() {
home := strings.Trim(a.Home, "/")
seen[home] = true
out = append(out, accessibleRoot{Path: home, Name: "我的空间", Mask: permString(a.MaskFor(home)), Home: true})
}
for _, rule := range a.Rules {
p := strings.Trim(rule.Dir, "/")
if p == "" || seen[p] || rule.Deny || strings.ContainsAny(p, "*?") {
continue
}
m := a.MaskFor(p)
if !m.HasRead() {
continue
}
seen[p] = true
out = append(out, accessibleRoot{Path: p, Name: path.Base(p), Mask: permString(m), Home: false})
}
writeJSON(w, map[string]any{"roots": out})
}
func (s *Server) password(w http.ResponseWriter, r *http.Request) { func (s *Server) password(w http.ResponseWriter, r *http.Request) {
a := account(r, s) a := account(r, s)
if a == nil || a.Username == "" { if a == nil || a.Username == "" {
@@ -131,6 +131,13 @@ func TestUserManagementAndPrivateHome(t *testing.T) {
if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"R\"") { if rr.Code != http.StatusOK || !strings.Contains(rr.Body.String(), "\"currentMask\":\"R\"") {
t.Fatalf("shared read rule: %d %s", rr.Code, rr.Body.String()) t.Fatalf("shared read rule: %d %s", rr.Code, rr.Body.String())
} }
req = httptest.NewRequest(http.MethodGet, "/chfs/roots", 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(), "我的空间") || !strings.Contains(rr.Body.String(), "public") {
t.Fatalf("accessible roots: %d %s", rr.Code, rr.Body.String())
}
var saved []map[string]any var saved []map[string]any
data, err := os.ReadFile(usersFile) data, err := os.ReadFile(usersFile)
if err != nil { if err != nil {
+3 -1
View File
@@ -25,7 +25,7 @@ table{width:100%;border-collapse:collapse}th,td{text-align:left;padding:13px 15p
<main> <main>
<div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div> <div id="serverNotice">__CHFS_NOTICE__</div><div id="notice"></div>
<div class="toolbar"> <div class="toolbar">
<button onclick="goUp()">上一级</button><button onclick="refresh()">刷新</button> <button onclick="goUp()">上一级</button><button onclick="refresh()">刷新</button><button class="ghost" onclick="showRoots()">位置</button>
<button id="newFolderBtn" onclick="newFolder()">新建文件夹</button><button id="newTextBtn" onclick="newText()">新建文本</button> <button id="newFolderBtn" onclick="newFolder()">新建文件夹</button><button id="newTextBtn" onclick="newText()">新建文本</button>
<label><button id="uploadBtn">上传</button><input id="upload" type="file" multiple hidden></label> <label><button id="uploadBtn">上传</button><input id="upload" type="file" multiple hidden></label>
<input id="search" placeholder="搜索文件名,回车执行"> <input id="search" placeholder="搜索文件名,回车执行">
@@ -43,6 +43,8 @@ async function init(){try{me=await api("/chfs/me")}catch{me={username:"",home:""
function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin;$("pwdBtn").hidden=!me.username||!me.managed} function updateUser(){$("who").textContent=me.username?me.username: "访客";$("loginBtn").hidden=!!me.username;$("logoutBtn").hidden=!me.username;$("usersBtn").hidden=!me.admin;$("pwdBtn").hidden=!me.username||!me.managed}
async function refresh(){try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);currentMask=data.currentMask||"";const writable=currentMask.includes("W");$("newFolderBtn").disabled=!writable;$("newTextBtn").disabled=!writable;$("uploadBtn").disabled=!writable;$("search").disabled=!data.currentMask?.includes("R");render(data.files||[]);$("crumb").textContent="/"+current;$("notice").innerHTML=""}catch(e){$("notice").innerHTML='<div class="notice">'+esc(e.message)+"</div>"}} async function refresh(){try{const q=encodeURIComponent(current);const data=await api("/chfs/files?filepath="+q);currentMask=data.currentMask||"";const writable=currentMask.includes("W");$("newFolderBtn").disabled=!writable;$("newTextBtn").disabled=!writable;$("uploadBtn").disabled=!writable;$("search").disabled=!data.currentMask?.includes("R");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?"📁":"📄";const share=f.guestMask&&f.guestMask.includes("R")?'<button class="ghost" onclick="shareItem('+JSON.stringify(p)+')">分享</button>':"";const canRead=(f.mask||"").includes("R"),canWrite=(f.mask||"").includes("W"),canDelete=(f.mask||"").includes("D");const actions=(canRead?(f.dir?'<button class="ghost" onclick="downloadDir('+JSON.stringify(p)+')">ZIP</button>':'<button class="ghost" onclick="downloadFile('+JSON.stringify(p)+')">下载</button>'):"")+(canRead?share:"")+(canWrite?'<button class="ghost" onclick="renameItem('+JSON.stringify(p)+')">改名</button>':"")+(canDelete?'<button class="danger" onclick="removeItem('+JSON.stringify(p)+')">删除</button>':"");row.innerHTML='<td><div class="name"><span>'+icon+'</span>'+ (f.dir&&canRead?'<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">'+actions+'</div></td>';body.appendChild(row)}} 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?"📁":"📄";const share=f.guestMask&&f.guestMask.includes("R")?'<button class="ghost" onclick="shareItem('+JSON.stringify(p)+')">分享</button>':"";const canRead=(f.mask||"").includes("R"),canWrite=(f.mask||"").includes("W"),canDelete=(f.mask||"").includes("D");const actions=(canRead?(f.dir?'<button class="ghost" onclick="downloadDir('+JSON.stringify(p)+')">ZIP</button>':'<button class="ghost" onclick="downloadFile('+JSON.stringify(p)+')">下载</button>'):"")+(canRead?share:"")+(canWrite?'<button class="ghost" onclick="renameItem('+JSON.stringify(p)+')">改名</button>':"")+(canDelete?'<button class="danger" onclick="removeItem('+JSON.stringify(p)+')">删除</button>':"");row.innerHTML='<td><div class="name"><span>'+icon+'</span>'+ (f.dir&&canRead?'<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">'+actions+'</div></td>';body.appendChild(row)}}
async function showRoots(){try{const d=await api("/chfs/roots");const roots=d.roots||[];let html='<h2>选择位置</h2><div>';for(const r of roots)html+='<button class="ghost" style="display:block;width:100%;text-align:left;margin:8px 0" onclick="jumpRoot('+JSON.stringify(r.path)+')"><b>'+esc(r.name)+'</b><span class="meta"> · '+esc(r.path||"/")+' · '+esc(r.mask)+'</span></button>';if(!roots.length)html+='<div class="empty">没有可进入的共享位置</div>';html+='</div><div class="row"><button class="ghost" onclick="closeModal()">关闭</button></div>';show(html)}catch(e){alert(e.message)}}
function jumpRoot(p){current=p;closeModal();refresh()}
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 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 downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")}
function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)} function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)}