From e3fef60d60da246eca14a880956f8a985c8c93d9 Mon Sep 17 00:00:00 2001 From: OpenClaw Agent Date: Tue, 22 Sep 2026 05:33:34 +0800 Subject: [PATCH] fix: serve media previews with browser-compatible content --- internal/api/server.go | 43 ++++++++++++++++++++++++++++++------- internal/api/web/index.html | 6 +++--- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/internal/api/server.go b/internal/api/server.go index 7cccfeb..f8b3676 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -96,6 +96,7 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("/chfs/shared", s.shared) mux.HandleFunc("/chfs/shared/", s.shared) mux.HandleFunc("/chfs/preview", s.preview) + mux.HandleFunc("/chfs/thumbnail", s.thumbnail) mux.HandleFunc("/asset/api.html", s.apiDoc) mux.HandleFunc("/", s.fileHandler) h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -1131,36 +1132,62 @@ func (s *Server) preview(w http.ResponseWriter, r *http.Request) { } file := strings.Trim(strings.ReplaceAll(rawFile, "\\", "/"), "/") if file == "" { - http.Error(w, "file required", 400) + http.Error(w, "file required", http.StatusBadRequest) return } if !account(r, s).MaskFor(file).HasRead() { writeJSONError(w, http.StatusForbidden, "has no privilege") return } - if !s.ImagePreview { - writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") + fi, err := s.Store.Stat(file) + if err != nil || fi.IsDir() { + http.NotFound(w, r) return } f, err := s.Store.Open(file) if err != nil { - writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") + http.NotFound(w, r) + return + } + defer f.Close() + if ct := mime.TypeByExtension(strings.ToLower(path.Ext(fi.Name()))); ct != "" { + w.Header().Set("Content-Type", ct) + } + w.Header().Set("Content-Disposition", mime.FormatMediaType("inline", map[string]string{"filename": fi.Name()})) + w.Header().Set("Cache-Control", "private, max-age=60") + http.ServeContent(w, r, fi.Name(), fi.ModTime(), f) +} + +func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) { + rawFile := r.URL.Query().Get("file") + if unsafePath(rawFile) { + writeJSONError(w, http.StatusBadRequest, "invalid parameter") + return + } + file := strings.Trim(strings.ReplaceAll(rawFile, "\\", "/"), "/") + if file == "" || !account(r, s).MaskFor(file).HasRead() || !s.ImagePreview { + http.Error(w, "thumbnail unavailable", http.StatusForbidden) + return + } + f, err := s.Store.Open(file) + if err != nil { + http.NotFound(w, r) return } defer f.Close() src, _, err := image.Decode(f) if err != nil { - writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") + http.Error(w, "thumbnail unavailable", http.StatusUnsupportedMediaType) return } - thumb := transform.Resize(src, 64, 64, transform.Linear) + thumb := transform.Resize(src, 320, 240, transform.Linear) var buf bytes.Buffer if err := png.Encode(&buf, thumb); err != nil { - writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") + http.Error(w, "thumbnail unavailable", http.StatusInternalServerError) return } - w.Header().Set("Cache-Control", "max-age=2592000") w.Header().Set("Content-Type", "image/png") + w.Header().Set("Cache-Control", "private, max-age=300") _, _ = w.Write(buf.Bytes()) } diff --git a/internal/api/web/index.html b/internal/api/web/index.html index 420b397..3058b3c 100644 --- a/internal/api/web/index.html +++ b/internal/api/web/index.html @@ -71,7 +71,7 @@ function renderSortState(){["name","size","modified"].forEach(k=>{const el=$("so function emptyState(title,text,action=""){const writable=currentMask.includes("W");let buttons="";if(action==="folder"&&writable)buttons='
';if(action==="upload"&&writable)buttons='
';return '
'+svgIcon("folder")+'

'+esc(title)+'

'+esc(text)+'

'+buttons+'
'} function render(items){lastItems=sortItems(items);selected=new Set([...selected].filter(p=>lastItems.some(f=>(current?current+"/":"")+f.name===p)));renderList(lastItems);renderGrid(lastItems);updateSelection();renderSortState()} function renderList(items){const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=can("read",f),canWrite=can("write",f),canDelete=can("delete",f),selectedNow=selected.has(p),drag=' draggable="true"';const perms='';return '
'+iconFor(f)+''+esc(f.name)+'
'+(f.dir?"—":fmt(f.size))+''+esc(f.modified||"")+''+perms+'
'}).join(""):''+emptyState("这个文件夹还是空的","可以上传文件,或者创建一个新的文件夹。","upload")+''} -function renderGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),type=fileType(f),selectedNow=selected.has(p),drag=' draggable="true"';let thumb=iconFor(f);if(type==="image"&&canRead)thumb='';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');const meta=f.dir?'文件夹':fmt(f.size);return '
'+thumb+'
'+iconFor(f)+'
'+esc(f.name)+'
'+meta+''+(f.modified?''+esc(f.modified)+'':'')+'
'}).join(''):emptyState("这个文件夹还是空的","上传文件或创建内容后,它们会显示在这里。","upload")} +function renderGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=current?current+"/"+f.name:f.name,canRead=(f.mask||"").includes("R"),type=fileType(f),selectedNow=selected.has(p),drag=' draggable="true"';let thumb=iconFor(f);if(type==="image"&&canRead)thumb='';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');const meta=f.dir?'文件夹':fmt(f.size);return '
'+thumb+'
'+iconFor(f)+'
'+esc(f.name)+'
'+meta+''+(f.modified?''+esc(f.modified)+'':'')+'
'}).join(''):emptyState("这个文件夹还是空的","上传文件或创建内容后,它们会显示在这里。","upload")} function fileDragStart(e,p){const paths=selected.has(p)?[...selected]:[p];e.dataTransfer.effectAllowed="move";e.dataTransfer.setData("text/chfs-paths",JSON.stringify(paths));e.currentTarget.classList.add("dragging")} function fileDragEnd(e){e.currentTarget.classList.remove("dragging");document.querySelectorAll('.drop-target').forEach(el=>el.classList.remove('drop-target'))} function folderDragOver(e,target){if(!e.dataTransfer?.types.includes('text/chfs-paths'))return;const f=lastItems.find(x=>(current?current+'/':'')+x.name===target);if(!canTargetWrite(f))return;e.preventDefault();if(target===current||target===""||[...selected].some(p=>p===target||target.startsWith(p+'/')))return;e.dataTransfer.dropEffect="move";e.currentTarget.classList.add('drop-target')} @@ -145,7 +145,7 @@ function searchParent(f){const p=searchPath(f);return p.includes("/")?p.slice(0, function searchCan(f,mask){if(mask==="R")return can("read",f);if(mask==="W")return can("write",f);if(mask==="D")return can("delete",f);return false} function renderSearch(items){searchMode=true;searchItems=sortItems(items);selected=new Set([...selected].filter(p=>searchItems.some(f=>searchPath(f)===p)));$("crumb").textContent="搜索结果";$("files").parentElement.hidden=localStorage.getItem("chfs-view")==="grid";$("grid").hidden=localStorage.getItem("chfs-view")!=="grid";renderSearchList(searchItems);renderSearchGrid(searchItems);updateSelection();renderSortState()} function renderSearchList(items){const body=$("files");body.innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),canWrite=searchCan(f,"W"),canDelete=searchCan(f,"D"),selectedNow=selected.has(p),perms='';return '
'+iconFor(f)+''+esc(name)+'
'+(f.dir?"—":fmt(f.size))+''+esc(f.modified||"")+'
'+esc(searchParent(f))+'
'}).join(""):''+emptyState("没有找到匹配文件","换一个关键词试试,或者清空搜索框返回当前位置。")+''} -function renderSearchGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),type=fileType(f),selectedNow=selected.has(p);let thumb=iconFor(f);if(type==="image"&&canRead)thumb='';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');return '
'+thumb+'
'+iconFor(f)+'
'+esc(name)+'
'+esc(searchParent(f)||"全部文件")+''+(f.modified?''+esc(f.modified)+'':'')+'
'}).join(""):'
'+svgIcon("file")+'

没有找到匹配文件

换一个关键词试试,或者清空搜索框返回当前位置。

'} +function renderSearchGrid(items){$("grid").innerHTML=items.length?items.map(f=>{const p=searchPath(f),name=searchName(f),canRead=searchCan(f,"R"),type=fileType(f),selectedNow=selected.has(p);let thumb=iconFor(f);if(type==="image"&&canRead)thumb='';const tileClass=(f.dir?'folder-tile ':'')+(['video','audio'].includes(type)?'media-tile':'');return '
'+thumb+'
'+iconFor(f)+'
'+esc(name)+'
'+esc(searchParent(f)||"全部文件")+''+(f.modified?''+esc(f.modified)+'':'')+'
'}).join(""):'
'+svgIcon("file")+'

没有找到匹配文件

换一个关键词试试,或者清空搜索框返回当前位置。

'} function selectSearchItem(p,checked,e){if(checked){if(e?.shiftKey){const paths=searchItems.map(searchPath),anchor=selectionAnchor&&paths.includes(selectionAnchor)?selectionAnchor:[...selected][0],a=paths.indexOf(anchor),b=paths.indexOf(p);if(a>=0&&b>=0){const lo=Math.min(a,b),hi=Math.max(a,b);if(!(e.ctrlKey||e.metaKey))selected.clear();for(let i=lo;i<=hi;i++)selected.add(paths[i])}else selected.add(p)}else if(e?.ctrlKey||e?.metaKey)selected.add(p);else{selected.clear();selected.add(p)}selectionAnchor=p}else{selected.delete(p);selectionAnchor=p}updateSelection();renderSearch(searchItems)} function toggleSearchItem(p,checked,e){selectSearchItem(p,checked,e)} function searchMenu(e,f){e.preventDefault();e.stopPropagation();const p=searchPath(f);if(!selected.has(p)){selected.clear();selected.add(p)}updateSelection();let html='';if(!me?.username){if(can("read",f))html+=''}else{const canRead=can("read",f),canWrite=can("write",f),canDelete=can("delete",f);if(canRead)html+='';if(f.dir&&canRead)html+='';if(canWrite)html+='';if(canDelete)html+='';if(f.guestMask?.includes("R"))html+=''}if(!html)html='
没有可用操作
';$("context").innerHTML=html;$("context").style.left=Math.min(e.clientX,innerWidth-190)+"px";$("context").style.top=Math.min(e.clientY,innerHeight-220)+"px";$("context").style.display="block"} @@ -156,7 +156,7 @@ function previewFilesFor(p){const dir=dirOf(p),items=dir===current?lastItems:sea 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 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/thumbnail?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 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")}}