From d8c938a0c866972cc4d89a42b21b5e179d0141a4 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 04:55:52 +0800 Subject: [PATCH] feat: complete guest shared download flow --- internal/api/server.go | 41 ++++++++++++++----- internal/api/share_archive_test.go | 63 +++++++++++++++++++++++++++++- internal/api/web/index.html | 11 +++--- 3 files changed, 100 insertions(+), 15 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 8ddb111..7cccfeb 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -14,6 +14,7 @@ import ( _ "image/jpeg" "image/png" "io" + "mime" "mime/multipart" "net" "net/http" @@ -1039,6 +1040,18 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out}) } +func sharedPath(parts ...string) string { + encoded := make([]string, 0, len(parts)) + for _, part := range parts { + encoded = append(encoded, url.PathEscape(part)) + } + return "/chfs/shared/" + strings.Join(encoded, "/") +} + +func attachmentDisposition(name string) string { + return mime.FormatMediaType("attachment", map[string]string{"filename": name}) +} + func (s *Server) shared(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/chfs/shared" { location := "shared/" @@ -1061,33 +1074,43 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) { return } if fi.IsDir() { - w.Header().Set("Content-Disposition", `attachment;filename="shared"`) w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "private, no-cache") w.Header().Set("Last-Modified", fi.ModTime().UTC().Format(http.TimeFormat)) + if r.Method == http.MethodHead { + return + } + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } items, err := s.Store.List(p) if err != nil { http.NotFound(w, r) return } - _, _ = io.WriteString(w, "
\n")
+		_, _ = io.WriteString(w, "CHFS Shared

共享文件

"+html.EscapeString(p)+"

") for _, item := range items { - name := item.Name() - href := url.PathEscape(name) + child := path.Join(p, item.Name()) + if !account(r, s).MaskForEntry(child).HasRead() { + continue + } + href := sharedPath(strings.Split(strings.Trim(child, "/"), "/")...) if item.IsDir() { href += "/" } - _, _ = fmt.Fprintf(w, "%s\n", href, html.EscapeString(name)+func() string { + _, _ = fmt.Fprintf(w, "%s%s", html.EscapeString(href), html.EscapeString(item.Name()), func() string { if item.IsDir() { return "/" } return "" }()) } - _, _ = io.WriteString(w, "
\n") + _, _ = io.WriteString(w, "") return } - if r.Method != http.MethodGet { - http.NotFound(w, r) + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } f, err := s.Store.Open(p) @@ -1096,7 +1119,7 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) { return } defer f.Close() - w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment;filename="%s"`, fi.Name())) + w.Header().Set("Content-Disposition", attachmentDisposition(fi.Name())) http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) } diff --git a/internal/api/share_archive_test.go b/internal/api/share_archive_test.go index 2628278..7e4c2f5 100644 --- a/internal/api/share_archive_test.go +++ b/internal/api/share_archive_test.go @@ -7,6 +7,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" "chfs-ng/internal/auth" @@ -30,7 +31,7 @@ func TestSharedRoutesMatchCHFS(t *testing.T) { r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil) w = httptest.NewRecorder() s.Handler().ServeHTTP(w, r) - if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment;filename="hello.txt"` { + if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment; filename=hello.txt` { t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition")) } body, _ := io.ReadAll(w.Result().Body) @@ -39,6 +40,66 @@ func TestSharedRoutesMatchCHFS(t *testing.T) { } } +func TestSharedGuestFlowAndSafeURLs(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "docs"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "private"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "docs", "你好 #?.txt"), []byte("shared"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "private", "secret.txt"), []byte("secret"), 0o644); err != nil { + t.Fatal(err) + } + s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{ + Mask: auth.Read, + Rules: []auth.PathRule{{Dir: "private", Deny: true}}, + }}, + } + for _, tc := range []struct { + name, url string + want int + }{ + {"guest file", "/chfs/shared/docs/%E4%BD%A0%E5%A5%BD%20%23%3F.txt", http.StatusOK}, + {"denied file", "/chfs/shared/private/secret.txt", http.StatusNotFound}, + {"traversal", "/chfs/shared/../private/secret.txt", http.StatusNotFound}, + } { + t.Run(tc.name, func(t *testing.T) { + r := httptest.NewRequest(http.MethodGet, tc.url, nil) + w := httptest.NewRecorder() + if tc.name == "traversal" { + s.shared(w, r) + } else { + s.Handler().ServeHTTP(w, r) + } + if w.Code != tc.want { + t.Fatalf("status=%d want=%d body=%q", w.Code, tc.want, w.Body.String()) + } + }) + } + r := httptest.NewRequest(http.MethodGet, "/chfs/shared/docs", nil) + w := httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "你好 #?") { + t.Fatalf("shared directory listing status=%d body=%q", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "private") { + t.Fatalf("denied entry leaked in listing: %q", w.Body.String()) + } + r = httptest.NewRequest(http.MethodHead, "/chfs/shared/docs/%E4%BD%A0%E5%A5%BD%20%23%3F.txt", nil) + w = httptest.NewRecorder() + s.Handler().ServeHTTP(w, r) + if w.Code != http.StatusOK || w.Body.Len() != 0 { + t.Fatalf("HEAD status=%d body=%d", w.Code, w.Body.Len()) + } + if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "attachment") { + t.Fatalf("disposition=%q", got) + } +} + func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) { root := t.TempDir() dir := filepath.Join(root, "pack") diff --git a/internal/api/web/index.html b/internal/api/web/index.html index 6db68d5..1d8de45 100644 --- a/internal/api/web/index.html +++ b/internal/api/web/index.html @@ -75,7 +75,8 @@ function folderDragLeave(e){e.currentTarget.classList.remove('drop-target')} async function folderDrop(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;e.preventDefault();e.stopPropagation();e.currentTarget.classList.remove('drop-target');let paths=[];try{paths=JSON.parse(e.dataTransfer.getData('text/chfs-paths')||'[]')}catch{};const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!f?.dir||!paths.length)return;if(paths.some(p=>p===target||target.startsWith(p+'/')))return fail('不能移动到自身或子目录');const base=target.replace(/^\/+|\/+$/g,'');await runOperation('移动 '+paths.length+' 项',async()=>{let done=0,failed=[],renamed=[];for(const source of paths){const name=source.split('/').pop(),destination=base?base+'/'+name:name;try{const actual=await transferWithConflict(source,destination,false);if(actual!==destination)renamed.push(name+' → '+actual.split('/').pop());done++}catch(err){failed.push(name+': '+err.message)}}selected.clear();await refresh();if(failed.length||renamed.length)show('

移动完成

'+esc('成功 '+done+' 项,失败 '+failed.length+' 项'+(renamed.length?'。\n\n自动重命名:\n'+renamed.join('\n'):'')+(failed.length?'。\n\n失败:\n'+failed.join('\n'):''))+'
');else toast('已移动 '+done+' 项')})} function handleItemClick(e,p){if(!me.username){guestItemActions(p);return}if(window.matchMedia&&window.matchMedia('(max-width:700px)').matches){activateItem(p);return}selectItem(p,true,e)} function activateItem(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)} -function guestItemActions(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f||!(f.mask||"").includes("R")){toast("当前项目没有访问权限",true);return}if(f.dir){openDir(p);return}const url=new URL("/chfs/shared/"+encodeURI(p),location.href).href;let html='

'+esc(f.name)+'

访客可以直接下载或复制分享链接。
';show(html)} +function sharedURL(p){return new URL("/chfs/shared/"+p.split("/").map(encodeURIComponent).join("/"),location.href).href} +function guestItemActions(p){const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f||!(f.mask||"").includes("R")){toast("当前项目没有访问权限",true);return}if(f.dir){openDir(p);return}const url=sharedURL(p);let html='

'+esc(f.name)+'

访客可以直接下载或复制分享链接。
';show(html)} async function guestCopyShare(url){try{await navigator.clipboard.writeText(url);toast("分享链接已复制");closeModal()}catch{show('

分享链接

')}} function toggleTile(p){selectItem(p,!selected.has(p))} function openSelected(){if(selected.size!==1)return;const p=[...selected][0];if(searchMode){const f=searchItems.find(x=>x.path===p);if(f)openSearchResult(f);return}const f=lastItems.find(x=>(current?current+"/":"")+x.name===p);if(!f)return;if(f.dir&&(f.mask||"").includes("R"))openDir(p);else if((f.mask||"").includes("R"))previewFile(p)} @@ -112,9 +113,9 @@ function jumpRoot(p){current=p;navigationRoot=p;closeModal();refresh()} function renderBreadcrumb(){const el=$("crumb"),parts=current?current.split("/").filter(Boolean):[];const rootName=navigationRoot?(navigationRoot===me?.home?'我的空间':'当前位置'): '全部文件';let html='';let acc='';for(let i=0;i'+esc(parts[i])+''}el.innerHTML=html} function navigateTo(p){if(navigationRoot){if(p!==navigationRoot&&!p.startsWith(navigationRoot+'/'))return}else if(p!==''&&!me?.admin)return;current=p;refresh()} function openDir(p){current=p;refresh()}function goUp(){if(!current||current===navigationRoot)return;let p=current.split("/");p.pop();let n=p.join("/");if(navigationRoot&&!(n===navigationRoot||n.startsWith(navigationRoot+"/")))n=navigationRoot;current=n;refresh()} -function downloadFile(p){location.href="/chfs/shared/"+encodeURIComponent(p).replace(/%2F/g,"/")} +function downloadFile(p){location.href=sharedURL(p)} function downloadDir(p){location.href="/chfs/downloaddir/"+encodeURI(p)} -async function shareItem(p){const url=new URL("/chfs/shared/"+encodeURI(p),location.href).href;try{await navigator.clipboard.writeText(url);toast("分享链接已复制")}catch{show("

分享链接

")}} +async function shareItem(p){const url=sharedURL(p);try{await navigator.clipboard.writeText(url);toast("分享链接已复制")}catch{show("

分享链接

")}} async function renameItem(p){const name=p.split("/").pop(),n=await ask("重命名","新名称",name);if(!n||n===name)return;const dest=(p.split("/").slice(0,-1).concat(n)).join("/");await runOperation("正在重命名",async()=>{try{await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:dest})});selected.clear();await refresh();toast("已重命名")}catch(e){if(e.status===400){const retry=await ask("目标已存在","请输入新的名称",n);if(retry&&retry!==n){try{const retryDest=(p.split("/").slice(0,-1).concat(retry)).join("/");await api("/chfs/rename",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({old:p,new:retryDest})});selected.clear();await refresh();toast("已重命名")}catch(err){fail("重命名失败:"+err.message)}}}else fail("重命名失败:"+e.message)}})} async function removeItem(p){if(!await confirmBox("确认删除","确定删除 "+p+"?"))return;await runOperation("正在删除",async()=>{try{await api("/chfs/rmfiles",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});selected.delete(p);await refresh();toast("已删除")}catch(e){fail("删除失败:"+e.message)}})} async function newFolder(){const n=await ask("新建文件夹","文件夹名称");if(!n)return;const p=current?current+"/"+n:n;toast("正在创建文件夹…");try{await api("/chfs/newdir",{method:"POST",headers:{"Content-Type":"application/x-www-form-urlencoded"},body:new URLSearchParams({filepath:p})});await refresh();toast("文件夹已创建")}catch(e){fail(e.message)}} @@ -152,7 +153,7 @@ function galleryOpen(p){return openGallery(p)} function dirOf(p){return String(p||'').split('/').slice(0,-1).join('/')} function galleryMove(delta){if(!galleryFiles.length)return;galleryIndex=(galleryIndex+delta+galleryFiles.length)%galleryFiles.length;const f=galleryFiles[galleryIndex],path=f.path||(current?current+'/'+f.name:f.name);renderGallery(path)} function preloadGallery(){for(const offset of [-1,1]){const f=galleryFiles[(galleryIndex+offset+galleryFiles.length)%galleryFiles.length];if(f){const img=new Image();img.src="/chfs/preview?file="+encodeURIComponent(f.path)}}} -function renderGallery(path){const f=galleryFiles[galleryIndex];if(!f)return;const p=path||((f.path)||((current?current+"/":"")+f.name)),n=f.name,src="/chfs/preview?file="+encodeURIComponent(p);preloadGallery();show('
'+esc(n)+''+(galleryIndex+1)+' / '+galleryFiles.length+'
'+esc(n)+'
',true)} +function renderGallery(path){const f=galleryFiles[galleryIndex];if(!f)return;const p=path||((f.path)||((current?current+"/":"")+f.name)),n=f.name,src="/chfs/preview?file="+encodeURIComponent(p);preloadGallery();show('
'+esc(n)+''+(galleryIndex+1)+' / '+galleryFiles.length+'
'+esc(n)+'
',true)} function mediaKeydown(e){const mediaActive=!!activeMedia&&($('modal').classList.contains('show')||$('miniPlayer'));if(mediaActive){if(e.key===' '){e.preventDefault();activeMedia.paused?activeMedia.play():activeMedia.pause()}else if(e.key==='ArrowLeft'||e.key==='ArrowRight'){e.preventDefault();activeMedia.currentTime=Math.max(0,activeMedia.currentTime+(e.key==='ArrowLeft'?-5:5))}else if((e.key==='['||e.key===']')&&activeMedia.playbackRate){e.preventDefault();activeMedia.playbackRate=Math.max(.25,Math.min(2,activeMedia.playbackRate+(e.key==='['?-.25:.25)));toast("播放速度 "+activeMedia.playbackRate+"x")}}if($('modal').classList.contains('show')&&galleryFiles.length&&(e.key==='ArrowLeft'||e.key==='ArrowRight')){e.preventDefault();galleryMove(e.key==='ArrowLeft'?-1:1)}} function setPlaybackRate(v){if(activeMedia){activeMedia.playbackRate=Number(v);toast("播放速度 "+v+"x")}} addEventListener('keydown',mediaKeydown); @@ -163,7 +164,7 @@ function editText(p,content){window._editorDirty=false;show('
';activeMedia=host.querySelector('audio');closeModal();activeMedia?.focus()} -async function previewFile(p){const n=p.split('/').pop(),ext=n.toLowerCase().split('.').pop(),url='/chfs/preview?file='+encodeURIComponent(p),media=['mp4','webm','mov','mkv'].includes(ext),audio=['mp3','wav','flac','m4a','ogg'].includes(ext),image=['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext),pdf=ext==='pdf',text=['txt','md','markdown','json','yaml','yml','xml','csv','log','js','ts','jsx','tsx','css','scss','html','htm','go','rs','py','java','c','cpp','h','hpp','sh','sql','toml','ini','conf'].includes(ext);if(image){return openGallery(p)}let content='';if(media){show('
'+esc(n)+'
空格暂停 · ← / → 快进 5 秒
',true);activeMedia=$('.preview-stage video');activeMedia?.focus();return}if(audio){startMiniPlayer(url,n);return}if(pdf){show('
'+esc(n)+'
',true);return}if(text){try{const r=await fetch(url);if(!r.ok)throw Error();content=await r.text();const targetFile=searchMode?searchItems.find(f=>searchPath(f)===p):lastItems.find(f=>(current?current+'/':'')+f.name===p),canEdit=String(targetFile?.mask||currentMask).includes('W');show('
'+esc(n)+''+(canEdit?'':'')+'
'+esc(content)+'
',true);window._previewText=content}catch{show('
无法读取文本内容
')}}else show('
暂不支持该文件类型的在线预览
')} +async function previewFile(p){const n=p.split('/').pop(),ext=n.toLowerCase().split('.').pop(),url='/chfs/preview?file='+encodeURIComponent(p),media=['mp4','webm','mov','mkv'].includes(ext),audio=['mp3','wav','flac','m4a','ogg'].includes(ext),image=['png','jpg','jpeg','gif','webp','bmp','svg'].includes(ext),pdf=ext==='pdf',text=['txt','md','markdown','json','yaml','yml','xml','csv','log','js','ts','jsx','tsx','css','scss','html','htm','go','rs','py','java','c','cpp','h','hpp','sh','sql','toml','ini','conf'].includes(ext);if(image){return openGallery(p)}let content='';if(media){show('
'+esc(n)+'
空格暂停 · ← / → 快进 5 秒
',true);activeMedia=$('.preview-stage video');activeMedia?.focus();return}if(audio){startMiniPlayer(url,n);return}if(pdf){show('
'+esc(n)+'
',true);return}if(text){try{const r=await fetch(url);if(!r.ok)throw Error();content=await r.text();const targetFile=searchMode?searchItems.find(f=>searchPath(f)===p):lastItems.find(f=>(current?current+'/':'')+f.name===p),canEdit=String(targetFile?.mask||currentMask).includes('W');show('
'+esc(n)+''+(canEdit?'':'')+'
'+esc(content)+'
',true);window._previewText=content}catch{show('
无法读取文本内容
')}}else show('
暂不支持该文件类型的在线预览
')} function changePassword(){show('

修改密码

')} async function savePassword(){try{await api("/chfs/password",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({currentPassword:$("cp").value,password:$("npw").value})});toast("密码已更新");closeModal()}catch(e){fail(e.message)}}