Compare commits

...
19 Commits
Author SHA1 Message Date
OpenClaw Agent c4dd76be56 docs: update README with recent UI, permission and performance changelog 2026-09-23 06:36:45 +08:00
OpenClaw Agent 7d263c916a fix: repair ask dialog submission 2026-09-23 06:28:11 +08:00
OpenClaw Agent a1caeff746 ui: align desktop shell and file actions 2026-09-23 05:59:05 +08:00
OpenClaw Agent ae6de3e047 fix: stabilize file table columns across browsers 2026-09-23 05:32:01 +08:00
OpenClaw Agent 7d3a5c13fd fix: balance mobile file list columns 2026-09-23 05:23:10 +08:00
OpenClaw Agent e1e8baccc9 fix: keep mobile file actions visible 2026-09-23 05:17:49 +08:00
OpenClaw Agent bc45dac945 fix: disable file selection for guests 2026-09-23 05:03:48 +08:00
OpenClaw Agent d3c9acf674 fix: restore danger button styling 2026-09-23 04:54:05 +08:00
OpenClaw Agent 9af81fdc8d perf: tighten thumbnail cache memory usage 2026-09-22 17:39:11 +08:00
OpenClaw Agent a2113f831d ui: standardize button hierarchy and modal interactions 2026-09-22 15:32:40 +08:00
OpenClaw Agent e6806de5f6 perf: cache generated image thumbnails 2026-09-22 05:51:06 +08:00
OpenClaw Agent f799104fb2 fix: polish media preview interactions 2026-09-22 05:44:38 +08:00
OpenClaw Agent e3fef60d60 fix: serve media previews with browser-compatible content 2026-09-22 05:33:34 +08:00
OpenClaw Agent 6b86eb4185 refactor: centralize frontend permission checks 2026-09-22 05:27:44 +08:00
OpenClaw Agent 11a3a2ef4c fix: restrict guest file actions 2026-09-22 05:24:24 +08:00
OpenClaw Agent 59aef8fed8 fix: escape inline file action arguments 2026-09-22 05:17:41 +08:00
OpenClaw Agent 2690888a21 fix: restore file manager interactions 2026-09-22 05:07:31 +08:00
OpenClaw Agent d8c938a0c8 feat: complete guest shared download flow 2026-09-22 04:55:52 +08:00
OpenClaw Agent 73c05ef112 fix: treat guest as unauthenticated in user state 2026-09-22 04:50:44 +08:00
7 changed files with 470 additions and 95 deletions
+5
View File
@@ -0,0 +1,5 @@
# Local build artifacts
bin/
# Local/debug runtime configuration
chfs.ini
+12
View File
@@ -10,6 +10,9 @@ CHFS-NG 是基于 CHFS 思路重构的轻量级、自托管 Web 文件管理器
- 上传队列、进度、取消与重试,支持文件夹拖拽上传 - 上传队列、进度、取消与重试,支持文件夹拖拽上传
- 动态用户管理与独立个人私有目录 - 动态用户管理与独立个人私有目录
- 多根目录与目录级读写/删除权限 - 多根目录与目录级读写/删除权限
- 完善的权限体系:支持访客/只读/读写/删除动态权限控制及安全防御
- 缩略图本地缓存与流式预览优化,低内存开销
- 桌面端(1080P/2K/4K)与移动端自适应布局,视觉规整与操作闭环
- 分享、二维码移动传输、WebDAV - 分享、二维码移动传输、WebDAV
- 健康检查:/healthz - 健康检查:/healthz
- 构建产物使用 -s -w,适合轻量 NAS - 构建产物使用 -s -w,适合轻量 NAS
@@ -145,5 +148,14 @@ docker run -d --name chfs-ng --restart unless-stopped -p 8080:80 -v /srv/chfs:/d
- 公开部署时建议配合防火墙/反向代理 - 公开部署时建议配合防火墙/反向代理
- WebDAV 优先使用 HTTPS - WebDAV 优先使用 HTTPS
## 更新日志
### 2026-09-23
- **前端交互与弹窗修复**:修复 `ask()` 弹窗中事件监听与回车提交逻辑,支持新建文件夹/新建文本顺畅操作。
- **桌面端大屏视口适配**:放宽主容器宽度至 1600px 并动态对齐顶部导航栏边距,补全表格操作列,消除 PC 端右侧大屏留白断层。
- **移动端与跨浏览器表格对齐**:固化列表各列宽度(`table-layout: fixed`),优化移动端触屏操作栏显示。
- **访客权限与交互收紧**:统一未登录访客的多选及操作限制,修复危险操作按钮层叠样式。
- **缩略图缓存机制**:引入生成缓存与内存保护机制,加快大图加载并降低宿主机内存消耗。
## License ## License
本项目许可证以仓库中的 LICENSE 文件为准。 本项目许可证以仓库中的 LICENSE 文件为准。
+117 -41
View File
@@ -14,6 +14,7 @@ import (
_ "image/jpeg" _ "image/jpeg"
"image/png" "image/png"
"io" "io"
"mime"
"mime/multipart" "mime/multipart"
"net" "net"
"net/http" "net/http"
@@ -36,25 +37,27 @@ import (
var webIndex []byte var webIndex []byte
type Server struct { type Server struct {
Store storage.Storage Store storage.Storage
Auth *auth.Manager Auth *auth.Manager
Sessions map[string]*auth.Account Sessions map[string]*auth.Account
SessionExpiry map[string]time.Time SessionExpiry map[string]time.Time
SessionMu sync.RWMutex SessionMu sync.RWMutex
SessionTimeout int SessionTimeout int
JWTSecret string JWTSecret string
WebDAVRoot string WebDAVRoot string
WebDAVFS webdav.FileSystem WebDAVFS webdav.FileSystem
Allow string Allow string
MultiRoot bool MultiRoot bool
RemoveMode int RemoveMode int
ImagePreview bool ImagePreview bool
FolderDownload string FolderDownload string
HTMLTitle string HTMLTitle string
HTMLNotice string HTMLNotice string
Log *opslog.Logger Log *opslog.Logger
UserStore *auth.UserStore UserStore *auth.UserStore
UserHome string UserHome string
ThumbnailCache *thumbnailCache
thumbnailCacheOnce sync.Once
} }
type fileInfo struct { type fileInfo struct {
Name string `json:"name"` Name string `json:"name"`
@@ -70,6 +73,7 @@ type fileInfo struct {
func (s *Server) Handler() http.Handler { func (s *Server) Handler() http.Handler {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.health) mux.HandleFunc("/healthz", s.health)
mux.HandleFunc("/favicon.ico", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) })
mux.HandleFunc("/webdav/", s.webdav) mux.HandleFunc("/webdav/", s.webdav)
mux.HandleFunc("/webdav", s.webdav) mux.HandleFunc("/webdav", s.webdav)
mux.HandleFunc("/chfs/files", s.files) mux.HandleFunc("/chfs/files", s.files)
@@ -95,6 +99,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("/chfs/shared", s.shared) mux.HandleFunc("/chfs/shared", s.shared)
mux.HandleFunc("/chfs/shared/", s.shared) mux.HandleFunc("/chfs/shared/", s.shared)
mux.HandleFunc("/chfs/preview", s.preview) mux.HandleFunc("/chfs/preview", s.preview)
mux.HandleFunc("/chfs/thumbnail", s.thumbnail)
mux.HandleFunc("/asset/api.html", s.apiDoc) mux.HandleFunc("/asset/api.html", s.apiDoc)
mux.HandleFunc("/", s.fileHandler) mux.HandleFunc("/", s.fileHandler)
h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { h := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -563,7 +568,14 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusUnauthorized, "not authenticated") writeJSONError(w, http.StatusUnauthorized, "not authenticated")
return return
} }
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}) // The anonymous account is an authorization identity, not a logged-in user.
// Keep its internal username for permission matching, but expose an empty
// username to the UI so an initial visit is correctly shown as "访客".
username := a.Username
if a == s.Auth.Guest {
username = ""
}
writeJSON(w, map[string]any{"username": username, "home": a.Home, "admin": a.Admin, "mask": permString(a.MaskFor(a.Home)), "managed": a.PasswordHash != "", "enabled": a.Enabled})
} }
type accessibleRoot struct { type accessibleRoot struct {
@@ -1032,6 +1044,18 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out}) 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) { func (s *Server) shared(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/chfs/shared" { if r.URL.Path == "/chfs/shared" {
location := "shared/" location := "shared/"
@@ -1054,33 +1078,43 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) {
return return
} }
if fi.IsDir() { if fi.IsDir() {
w.Header().Set("Content-Disposition", `attachment;filename="shared"`)
w.Header().Set("Content-Type", "text/html; charset=utf-8") 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)) 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) items, err := s.Store.List(p)
if err != nil { if err != nil {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
_, _ = io.WriteString(w, "<pre>\n") _, _ = io.WriteString(w, "<!doctype html><meta charset=\"utf-8\"><title>CHFS Shared</title><style>body{font:15px system-ui,sans-serif;max-width:760px;margin:40px auto;padding:0 18px}a{display:block;padding:10px 0;text-decoration:none}</style><h2>共享文件</h2><p>"+html.EscapeString(p)+"</p>")
for _, item := range items { for _, item := range items {
name := item.Name() child := path.Join(p, item.Name())
href := url.PathEscape(name) if !account(r, s).MaskForEntry(child).HasRead() {
continue
}
href := sharedPath(strings.Split(strings.Trim(child, "/"), "/")...)
if item.IsDir() { if item.IsDir() {
href += "/" href += "/"
} }
_, _ = fmt.Fprintf(w, "<a href=\"%s\">%s</a>\n", href, html.EscapeString(name)+func() string { _, _ = fmt.Fprintf(w, "<a href=\"%s\">%s%s</a>", html.EscapeString(href), html.EscapeString(item.Name()), func() string {
if item.IsDir() { if item.IsDir() {
return "/" return "/"
} }
return "" return ""
}()) }())
} }
_, _ = io.WriteString(w, "</pre>\n") _, _ = io.WriteString(w, "</body></html>")
return return
} }
if r.Method != http.MethodGet { if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.NotFound(w, r) http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return return
} }
f, err := s.Store.Open(p) f, err := s.Store.Open(p)
@@ -1089,7 +1123,7 @@ func (s *Server) shared(w http.ResponseWriter, r *http.Request) {
return return
} }
defer f.Close() 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) http.ServeContent(w, r, fi.Name(), fi.ModTime(), f)
} }
@@ -1101,37 +1135,79 @@ func (s *Server) preview(w http.ResponseWriter, r *http.Request) {
} }
file := strings.Trim(strings.ReplaceAll(rawFile, "\\", "/"), "/") file := strings.Trim(strings.ReplaceAll(rawFile, "\\", "/"), "/")
if file == "" { if file == "" {
http.Error(w, "file required", 400) http.Error(w, "file required", http.StatusBadRequest)
return return
} }
if !account(r, s).MaskFor(file).HasRead() { if !account(r, s).MaskFor(file).HasRead() {
writeJSONError(w, http.StatusForbidden, "has no privilege") writeJSONError(w, http.StatusForbidden, "has no privilege")
return return
} }
if !s.ImagePreview { fi, err := s.Store.Stat(file)
writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") if err != nil || fi.IsDir() {
http.NotFound(w, r)
return return
} }
f, err := s.Store.Open(file) f, err := s.Store.Open(file)
if err != nil { if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") http.NotFound(w, r)
return return
} }
defer f.Close() defer f.Close()
src, _, err := image.Decode(f) 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 { if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") http.NotFound(w, r)
return return
} }
thumb := transform.Resize(src, 64, 64, transform.Linear) defer f.Close()
var buf bytes.Buffer fi, err := f.Stat()
if err := png.Encode(&buf, thumb); err != nil { if err != nil {
writeJSONError(w, http.StatusInternalServerError, "Open image file fail!") http.NotFound(w, r)
return
}
key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()}
s.thumbnailCacheOnce.Do(func() {
if s.ThumbnailCache == nil {
s.ThumbnailCache = newThumbnailCache()
}
})
data, err := s.ThumbnailCache.Do(key, func() ([]byte, error) {
src, _, err := image.Decode(f)
if err != nil {
return nil, err
}
thumb := transform.Resize(src, 320, 240, transform.Linear)
var buf bytes.Buffer
if err := png.Encode(&buf, thumb); err != nil {
return nil, err
}
return append([]byte(nil), buf.Bytes()...), nil
})
if err != nil {
http.Error(w, "thumbnail unavailable", http.StatusUnsupportedMediaType)
return return
} }
w.Header().Set("Cache-Control", "max-age=2592000")
w.Header().Set("Content-Type", "image/png") w.Header().Set("Content-Type", "image/png")
_, _ = w.Write(buf.Bytes()) w.Header().Set("Cache-Control", "private, max-age=300")
_, _ = w.Write(data)
} }
func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) { func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
+62 -1
View File
@@ -7,6 +7,7 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"chfs-ng/internal/auth" "chfs-ng/internal/auth"
@@ -30,7 +31,7 @@ func TestSharedRoutesMatchCHFS(t *testing.T) {
r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil) r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil)
w = httptest.NewRecorder() w = httptest.NewRecorder()
s.Handler().ServeHTTP(w, r) 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")) t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition"))
} }
body, _ := io.ReadAll(w.Result().Body) 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) { func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) {
root := t.TempDir() root := t.TempDir()
dir := filepath.Join(root, "pack") dir := filepath.Join(root, "pack")
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"container/list"
"sync"
)
type thumbnailCacheKey struct {
Path string
Size int64
ModTime int64
}
type thumbnailCacheEntry struct {
key thumbnailCacheKey
data []byte
}
type thumbnailFlight struct {
done chan struct{}
data []byte
err error
}
type thumbnailCache struct {
mu sync.Mutex
items map[thumbnailCacheKey]*list.Element
order *list.List
flights map[thumbnailCacheKey]*thumbnailFlight
bytes int64
maxBytes int64
maxItems int
}
func newThumbnailCache() *thumbnailCache {
return &thumbnailCache{
items: make(map[thumbnailCacheKey]*list.Element),
order: list.New(),
flights: make(map[thumbnailCacheKey]*thumbnailFlight),
maxBytes: 8 << 20,
maxItems: 64,
}
}
func (c *thumbnailCache) Do(key thumbnailCacheKey, fn func() ([]byte, error)) ([]byte, error) {
c.mu.Lock()
if e, ok := c.items[key]; ok {
c.order.MoveToFront(e)
data := append([]byte(nil), e.Value.(thumbnailCacheEntry).data...)
c.mu.Unlock()
return data, nil
}
if f, ok := c.flights[key]; ok {
c.mu.Unlock()
<-f.done
return f.data, f.err
}
f := &thumbnailFlight{done: make(chan struct{})}
c.flights[key] = f
c.mu.Unlock()
data, err := fn()
if err == nil && len(data) > 0 && int64(len(data)) <= c.maxBytes {
c.mu.Lock()
c.putLocked(key, data)
f.data = data
f.err = err
delete(c.flights, key)
close(f.done)
c.mu.Unlock()
return data, nil
}
c.mu.Lock()
f.err = err
delete(c.flights, key)
close(f.done)
c.mu.Unlock()
return data, err
}
func (c *thumbnailCache) putLocked(key thumbnailCacheKey, data []byte) {
if e, ok := c.items[key]; ok {
old := e.Value.(thumbnailCacheEntry)
c.bytes -= int64(len(old.data))
e.Value = thumbnailCacheEntry{key: key, data: data}
c.bytes += int64(len(data))
c.order.MoveToFront(e)
} else {
e := c.order.PushFront(thumbnailCacheEntry{key: key, data: data})
c.items[key] = e
c.bytes += int64(len(data))
}
for c.bytes > c.maxBytes || c.order.Len() > c.maxItems {
e := c.order.Back()
if e == nil {
break
}
entry := e.Value.(thumbnailCacheEntry)
delete(c.items, entry.key)
c.bytes -= int64(len(entry.data))
c.order.Remove(e)
}
}
+88
View File
@@ -0,0 +1,88 @@
package api
import (
"errors"
"sync"
"sync/atomic"
"testing"
)
func TestThumbnailCacheDeduplicatesConcurrentBuilds(t *testing.T) {
c := newThumbnailCache()
key := thumbnailCacheKey{Path: "photo.jpg", Size: 10, ModTime: 20}
var builds atomic.Int32
const workers = 24
var wg sync.WaitGroup
results := make([][]byte, workers)
errs := make([]error, workers)
wg.Add(workers)
for i := 0; i < workers; i++ {
go func(i int) {
defer wg.Done()
results[i], errs[i] = c.Do(key, func() ([]byte, error) {
builds.Add(1)
return []byte("thumbnail"), nil
})
}(i)
}
wg.Wait()
if builds.Load() != 1 {
t.Fatalf("thumbnail builder ran %d times, want 1", builds.Load())
}
for i := range results {
if errs[i] != nil || string(results[i]) != "thumbnail" {
t.Fatalf("worker %d got result %q, err=%v", i, results[i], errs[i])
}
}
}
func TestThumbnailCacheDoesNotCacheErrors(t *testing.T) {
c := newThumbnailCache()
key := thumbnailCacheKey{Path: "broken.jpg"}
wantErr := errors.New("decode failed")
var builds atomic.Int32
for i := 0; i < 2; i++ {
_, err := c.Do(key, func() ([]byte, error) {
builds.Add(1)
return nil, wantErr
})
if !errors.Is(err, wantErr) {
t.Fatalf("got err %v, want %v", err, wantErr)
}
}
if builds.Load() != 2 {
t.Fatalf("error result was cached; builder ran %d times", builds.Load())
}
}
func TestThumbnailCacheEvictsByBound(t *testing.T) {
c := newThumbnailCache()
c.maxItems = 2
for i := 0; i < 3; i++ {
key := thumbnailCacheKey{Path: string(rune('a' + i))}
if _, err := c.Do(key, func() ([]byte, error) { return []byte("x"), nil }); err != nil {
t.Fatal(err)
}
}
c.mu.Lock()
defer c.mu.Unlock()
if len(c.items) != 2 || c.order.Len() != 2 {
t.Fatalf("cache size=%d/list=%d, want 2/2", len(c.items), c.order.Len())
}
}
func TestThumbnailCacheEvictsByBytes(t *testing.T) {
c := newThumbnailCache()
c.maxBytes = 3
for i := 0; i < 2; i++ {
key := thumbnailCacheKey{Path: string(rune('x' + i))}
if _, err := c.Do(key, func() ([]byte, error) { return []byte("xx"), nil }); err != nil {
t.Fatal(err)
}
}
c.mu.Lock()
defer c.mu.Unlock()
if c.bytes > c.maxBytes || len(c.items) != 1 {
t.Fatalf("cache bytes=%d items=%d, want <=%d/1", c.bytes, len(c.items), c.maxBytes)
}
}
File diff suppressed because one or more lines are too long