Compare commits
19
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4dd76be56 | ||
|
|
7d263c916a | ||
|
|
a1caeff746 | ||
|
|
ae6de3e047 | ||
|
|
7d3a5c13fd | ||
|
|
e1e8baccc9 | ||
|
|
bc45dac945 | ||
|
|
d3c9acf674 | ||
|
|
9af81fdc8d | ||
|
|
a2113f831d | ||
|
|
e6806de5f6 | ||
|
|
f799104fb2 | ||
|
|
e3fef60d60 | ||
|
|
6b86eb4185 | ||
|
|
11a3a2ef4c | ||
|
|
59aef8fed8 | ||
|
|
2690888a21 | ||
|
|
d8c938a0c8 | ||
|
|
73c05ef112 |
@@ -0,0 +1,5 @@
|
|||||||
|
# Local build artifacts
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Local/debug runtime configuration
|
||||||
|
chfs.ini
|
||||||
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-53
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user