Compare commits

...
4 Commits
13 changed files with 818 additions and 44 deletions
+34
View File
@@ -15,6 +15,10 @@ CHFS-NG 是基于 CHFS 思路重构的轻量级、自托管 Web 文件管理器
- 桌面端(1080P/2K/4K)与移动端自适应布局,视觉规整与操作闭环 - 桌面端(1080P/2K/4K)与移动端自适应布局,视觉规整与操作闭环
- 分享、二维码移动传输、WebDAV - 分享、二维码移动传输、WebDAV
- 健康检查:/healthz - 健康检查:/healthz
- 资源耗尽防护:上传、ZIP 打包、搜索与缩略图均有默认资源上限
- 缩略图先通过 DecodeConfig 校验像素面积,再执行完整解码,避免声明尺寸型图片导致内存耗尽
- 上传入口在 multipart 解析前使用 MaxBytesReader 限制请求体总量
- 反向代理场景支持可信代理配置,仅对可信来源解析 X-Forwarded-For / X-Real-IP
- 构建产物使用 -s -w,适合轻量 NAS - 构建产物使用 -s -w,适合轻量 NAS
## 快速开始 ## 快速开始
@@ -148,8 +152,38 @@ docker run -d --name chfs-ng --restart unless-stopped -p 8080:80 -v /srv/chfs:/d
- 公开部署时建议配合防火墙/反向代理 - 公开部署时建议配合防火墙/反向代理
- WebDAV 优先使用 HTTPS - WebDAV 优先使用 HTTPS
### 默认资源限制
以下限制默认启用,可通过配置文件调整;设置为 `0` 表示不启用对应限制。
| 配置项 | 默认值 | 说明 |
| --- | ---: | --- |
| `thumbnail-max-size` | 32 MiB | 单个缩略图源文件最大大小 |
| `thumbnail-max-pixels` | 40,000,000 | 缩略图源图片最大像素面积 |
| `zip-max-size` | 2 GiB | ZIP 打包源文件总大小上限 |
| `zip-max-files` | 5000 | 单次 ZIP 最大文件数 |
| `upload-max-size` | 2 GiB | 单个上传文件最大大小 |
| `upload-max-total-size` | 4 GiB | 单次上传请求总大小上限 |
| `upload-max-files` | 100 | 单次上传最大文件数 |
| `search-max-results` | 500 | 单次搜索最大返回结果数;超过后标记 `truncated` |
| `trusted-proxies` | 空 | 可信反向代理地址;仅可信代理的请求会使用 X-Forwarded-For / X-Real-IP |
上传请求的总大小限制会在 multipart 解析之前生效;缩略图则先读取图片配置头并检查尺寸,再进行完整图片解码。
生产环境如果使用反向代理,应显式配置 `trusted-proxies`,避免客户端伪造转发头影响 IP 记录或 IP Allow 规则。
## 更新日志 ## 更新日志
### 2026-10-03
- **安全审计整改**:`/chfs/exist` 强制执行读取权限检查。
- **资源耗尽防护**:增加缩略图文件大小/像素面积、ZIP 文件数/原始文件大小、上传文件数/单文件大小/总请求大小以及搜索结果上限。
- **缩略图 OOM 防护**:使用 `image.DecodeConfig` 在完整 Decode 前校验声明尺寸,阻断超大尺寸图片解压炸弹。
- **上传请求体防护**:在 `ParseMultipartForm` 前使用 `http.MaxBytesReader` 限制请求体,避免超大 multipart 请求先被 spool 到临时文件。
- **回收站清理**:系统 Trash 增加默认 30 天自动清理。
- **可信代理**:增加 `trusted-proxies` 配置,仅信任配置范围内的代理转发真实客户端 IP。
- **密码安全**:新密码使用 bcrypt,同时兼容已有 SHA-256 密码平滑迁移。
- **生产部署**:当前生产环境采用二进制 + systemd 部署;Docker 仍作为独立部署方式提供。
### 2026-09-23 ### 2026-09-23
- **前端交互与弹窗修复**:修复 `ask()` 弹窗中事件监听与回车提交逻辑,支持新建文件夹/新建文本顺畅操作。 - **前端交互与弹窗修复**:修复 `ask()` 弹窗中事件监听与回车提交逻辑,支持新建文件夹/新建文本顺畅操作。
- **桌面端大屏视口适配**:放宽主容器宽度至 1600px 并动态对齐顶部导航栏边距,补全表格操作列,消除 PC 端右侧大屏留白断层。 - **桌面端大屏视口适配**:放宽主容器宽度至 1600px 并动态对齐顶部导航栏边距,补全表格操作列,消除 PC 端右侧大屏留白断层。
+1 -1
View File
@@ -154,7 +154,7 @@ func main() {
} }
} }
} }
srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, JWTSecret: jwtSecret, WebDAVRoot: root, Allow: cfg.Allow, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome} srv := &api.Server{Store: store, Auth: mgr, Sessions: map[string]*auth.Account{}, SessionExpiry: map[string]time.Time{}, SessionTimeout: cfg.SessionTimeout, JWTSecret: jwtSecret, WebDAVRoot: root, Allow: cfg.Allow, TrustedProxies: cfg.TrustedProxies, MultiRoot: len(cfg.Path) > 1, RemoveMode: cfg.FileRemove, ImagePreview: cfg.ImagePreview, FolderDownload: cfg.FolderDownload, HTMLTitle: cfg.HTMLTitle, HTMLNotice: cfg.HTMLNotice, Log: opslog.New(cfg.Log), UserStore: users, UserHome: cfg.UserHome, ThumbnailMaxSize: cfg.ThumbnailMaxSize, ThumbnailMaxPixels: cfg.ThumbnailMaxPixels, ZipMaxSize: cfg.ZipMaxSize, ZipMaxFiles: cfg.ZipMaxFiles, UploadMaxSize: cfg.UploadMaxSize, UploadMaxTotalSize: cfg.UploadMaxTotalSize, UploadMaxFiles: cfg.UploadMaxFiles, SearchMaxResults: cfg.SearchMaxResults}
addr := ":" + itoa(cfg.Port) addr := ":" + itoa(cfg.Port)
log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove) log.Printf("chfs-ng listening on %s root=%s removeMode=%d", addr, filepath.Clean(root), cfg.FileRemove)
if cfg.SSLCert != "" && cfg.SSLKey != "" { if cfg.SSLCert != "" && cfg.SSLKey != "" {
+185
View File
@@ -0,0 +1,185 @@
# 审核专家 B:CHFS-NG 代码安全审计报告
- **审核人**:审核专家 B
- **日期**:2026-10-02
- **对象**:`/root/chfs-ng-research`(Go,11 个非测试源文件,约 3900 行)
- **生产环境**:`chfs-ng.service`(root 用户),`bin/chfs-ng -file chfs.ini`,监听 *:18999
- **验证**:`go vet ./...` 干净;`go test ./...` 全通过
- **结论**:**有条件通过** —— 代码层面的路径防御与权限检查扎实,但生产部署配置存在阻断项,另有 2 高/3 中需处理
---
## 【阻断】生产配置使用弱密码 admin123,明文存储且文件全局可读
**【严重度:阻断】**
**【位置】** `/root/chfs-ng-research/chfs.ini`(生产配置文件)
**【问题描述】**
生产 `chfs.ini` 中 `[admin]` 的密码为明文 `admin123`,文件权限为 `-rw-r--r--`(全局可读)。
服务监听 `*:18999`(所有网卡),主机防火墙 INPUT 策略为 ACCEPT 且无规则,等同于直接暴露公网。
远程攻击者无需任何前置条件,直接用 `admin/admin123` 登录即获得全部文件读写删除权限。
**【利用/触发条件】**
`curl -X POST http://<host>:18999/chfs/session -d "user=admin&pwd=admin123"` 即得管理员 JWT。
**【修复建议】**
1. 立即更换高强度密码;2. `chmod 600 chfs.ini`;3. 配置 `allow` IP 白名单或置于反向代理后;
4. 考虑主机防火墙限制 18999 入站。
---
## 【高】/chfs/exist 未鉴权的文件存在性预言机
**【严重度:高】**
**【位置】** `internal/api/server.go:512` `func (s *Server) exist`
**【问题描述】**
该端点全程未调用 `account(r, s)`,未做任何权限检查:`GET /chfs/exist?file=<path>` 返回 201/404
直接泄露任意路径是否存在。所有其他文件端点都有 `MaskFor` 检查,唯独此处遗漏,
与 README 宣称的"完善的权限体系"不一致。
**【利用/触发条件】**
当前生产 guest=r,攻击者本就可列目录,实际影响有限;但在 guest 被收紧的部署中,
该端点绕过权限模型造成信息泄漏。
**【修复建议】**
加上 `if !account(r, s).MaskFor(p).HasRead() { 403 }`,与其他端点保持一致。
---
## 【高】缩略图接口无文件大小限制,可被解压炸弹打 OOM
**【严重度:高】**
**【位置】** `internal/api/server.go:1164` `func (s *Server) thumbnail`
**【问题描述】**
`image.Decode(f)` 会把整个文件读入内存解码,而此前没有任何文件大小检查。
缓存层虽有 8MB/64 项上限(`thumbnail_cache.go`),但 decode 本身在缓存之外无限制。
攻击者(生产环境 guest 即有 read 权限)可请求超大文件或特制解压炸弹
(小文件声明超大宽高,如 PNG 头声明 100000x100000 → 数十 GB 内存分配),直接 OOM 杀死服务。
**【利用/触发条件】**
`GET /chfs/thumbnail?file=<大文件或炸弹图片>`,无需登录(guest 可读)。
**【修复建议】**
`Stat` 后检查 `fi.Size()`,超过阈值(如 32MB)直接拒绝;或对 decode 加内存上限。
---
## 【中】目录 ZIP 下载无总大小/数量限制,CPU 与带宽耗尽
**【严重度:中】**
**【位置】** `internal/api/server.go:1290` `downloadSelectedZip`、`1368` `downloadDir`
**【问题描述】**
两个接口把目录树流式打包为 zip 直接写回 response,全程无总字节数、无文件数上限
(`downloadSelectedZip` 仅限制了顶层 path 参数 ≤100 个,但每个可为超大目录;`downloadDir` 无任何限制)。
`zip.Deflate` 是 CPU 密集型,多并发请求可打满 CPU 并占满带宽。guest 可读即触发。
**【修复建议】**
增加可配置的打包上限(总大小、文件数),超限返回 413;或对该接口加并发信号量。
---
## 【中】上传无单文件/总量配额,可填满磁盘
**【严重度:中】**
**【位置】** `internal/api/server.go:838` `upload`、`937` `upload2`、`savePart`
**【问题描述】**
`ParseMultipartForm` 仅限制内存部分(64MB/128MB),`savePart` 的 `io.Copy(f, in)` 无任何大小上限。
有写权限的用户可上传超大文件填满磁盘。当前生产仅 admin 有写权限,实际影响有限,
但项目主打"动态权限控制"(会给普通用户开写权限),属于设计缺口。
**【修复建议】**
增加 `upload.max-size` 配置项,`savePart` 中用 `io.LimitReader` 截断并报错。
---
## 【中】默认删除进系统回收站且永不清理,磁盘被"已删除"文件占满
**【严重度:中】**
**【位置】** `internal/storage/storage.go` `Trash`;`internal/config/config.go` 默认 `FileRemove: 1`
**【问题描述】**
默认 `file.remove=1` 走 `wastebasket.Trash`(`~/.local/share/Trash`),该库无自动清理机制
(只有模式 2 的 `MoveToTrash` 有 30 天清理)。长期运行的服务删除的文件永久占用磁盘,
`df` 与用户认知不一致,最终表现为神秘的磁盘占满。
**【修复建议】**
对模式 1 也加定期清理(如 30 天),或在文档中明确说明并给出清理方式。
---
## 【中低】search 无结果数量上限
**【严重度:中低】**
**【位置】** `internal/api/server.go:1009` `func (s *Server) search`
**【问题描述】**
全树递归遍历,结果集无上限一次性 JSON 返回。超大目录树下内存与响应体膨胀,
guest 可直接触发。
**【修复建议】**
加 `limit` 参数(默认如 500),超限截断并提示。
---
## 【低】ipFilter 不识别 X-Forwarded-For,反代后 allow 白名单失效
**【严重度:低】**
**【位置】** `internal/api/server.go:2065` `ipFilter` / `clientIP`
**【问题描述】**
`clientIP` 只读 `r.RemoteAddr`。README 建议生产用 HTTPS 反向代理,
一旦接入反代,`allow` 看到的永远是代理 IP,白名单名存实亡(要么全放行要么全拒绝)。
**【修复建议】**
支持可信代理 + `X-Forwarded-For` 解析,或在文档中明确说明该限制。
---
## 【低】密码哈希为无盐 SHA256
**【严重度:低】**
**【位置】** `internal/auth/auth.go` `func PasswordHash`
**【问题描述】**
`users.json` 中存储 `hex(sha256(password))`,无盐。文件泄漏后彩虹表可直接破解弱密码。
**【修复建议】**
改用 bcrypt(`golang.org/x/crypto/bcrypt`),并做旧哈希的兼容迁移。
---
## 已验证为安全的关键点(正面结论)
1. **JWT 密钥管理正确**:`main.go` 在未配置 `jwt.secret` 时生成随机 32 字节密钥并以 0600 权限持久化
到 `users.json.jwt-secret`(生产已生效,文件存在)。`auth.go` 中的硬编码常量在 server 路径中不可达。
2. **路径穿越防御扎实**:`unsafePath`(NUL + `..` 段)与 `storage.real()` 双层防御;`real()` 对最近存在祖先
做 `EvalSymlinks`,连路径中间的符号链接逃逸都能拦住。多根目录经 `Multi` 路由后仍走同一检查。
3. **权限检查完整**:除 `exist` 外所有 mutating 端点都有 `MaskFor` 检查;`rename` 为 create-only;
`remove`/`transfer` 有递归 preflight;WebDAV 有独立 `webdavAuthFS` 包装,Move/Copy/RemoveAll 均有 `checkTree`。
4. **上传 preflight**:`upload`/`upload2` 全量校验后再落盘,失败回滚已保存分片;`createExclusive` 防并发覆盖。
5. **Cookie 安全**:JWT cookie 为 `HttpOnly`;生产 guest 为只读。
6. **生产密钥文件权限正确**:`users.json.jwt-secret` 为 0600。
---
## 总体结论
**有条件通过**。
代码本身质量不错:路径穿越防御、权限模型、并发处理都经过认真设计,测试全过、vet 干净。
真正的风险在**生产部署**:`admin123` 弱密码 + 公网直连 + 无防火墙是阻断项,必须立即处理。
代码层面需修:`exist` 鉴权缺失(高)、缩略图 OOM(高),以及 ZIP/上传/回收站/搜索的资源限制(中)。
修完阻断项和高优先级后可认为达到生产安全标准。
+4 -1
View File
@@ -10,4 +10,7 @@ require (
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
) )
require golang.org/x/sys v0.3.0 // indirect require (
golang.org/x/crypto v0.28.0 // indirect
golang.org/x/sys v0.26.0 // indirect
)
+4
View File
@@ -11,5 +11,9 @@ github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M= github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4= github.com/teambition/rrule-go v1.8.2/go.mod h1:Ieq5AbrKGciP1V//Wq8ktsTXwSwJHDD5mD/wLBGl3p4=
golang.org/x/crypto v0.28.0 h1:GBDwsMXVQi34v5CCYUm2jkJvu4cbtru2U4TN2PSyQnw=
golang.org/x/crypto v0.28.0/go.mod h1:rmgy+3RHxRZMyY0jjAJShp2zgEdOqj2AO7U0pYmeQ7U=
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ= golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+181
View File
@@ -0,0 +1,181 @@
package api
import (
"bytes"
"encoding/binary"
"hash/crc32"
"image"
"image/color"
"image/png"
"mime/multipart"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"chfs-ng/internal/auth"
"chfs-ng/internal/storage"
)
func TestAuditExistEnforcesReadPermission(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "secret.txt"), []byte("x"), 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.None}}}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/exist?file=secret.txt", nil)
s.exist(rr, req)
if rr.Code != 403 {
t.Fatalf("exist status=%d want 403", rr.Code)
}
}
func TestAuditThumbnailRejectsOversizeBeforeDecode(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "big.bin"), make([]byte, 64), 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxSize: 32}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/thumbnail?file=big.bin", nil)
s.thumbnail(rr, req)
if rr.Code != 413 {
t.Fatalf("thumbnail status=%d want 413", rr.Code)
}
}
func TestAuditZipLimitsUseOriginalFileSizes(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "a.txt"), make([]byte, 100), 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, ZipMaxSize: 99, ZipMaxFiles: 10}
a := &auth.Account{Mask: auth.Read}
if err := s.checkZipLimits(a, []string{""}); err == nil {
t.Fatal("expected zip size limit")
}
}
func TestAuditSearchLimit(t *testing.T) {
root := t.TempDir()
for _, name := range []string{"a.txt", "b.txt", "c.txt"} {
if err := os.WriteFile(filepath.Join(root, name), []byte(name), 0600); err != nil {
t.Fatal(err)
}
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, SearchMaxResults: 2}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/search?str=.txt", nil)
s.search(rr, req)
if rr.Code != 200 {
t.Fatalf("search status=%d", rr.Code)
}
if len(rr.Body.Bytes()) == 0 || !contains(rr.Body.String(), `"truncated":true`) {
t.Fatalf("search did not report truncation: %s", rr.Body.String())
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || func() bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}())
}
func TestAuditThumbnailPixelLimit(t *testing.T) {
root := t.TempDir()
img := image.NewRGBA(image.Rect(0, 0, 20, 20))
img.Set(0, 0, color.White)
f, err := os.Create(filepath.Join(root, "large.png"))
if err != nil {
t.Fatal(err)
}
if err := png.Encode(f, img); err != nil {
f.Close()
t.Fatal(err)
}
f.Close()
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxPixels: 100}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/thumbnail?file=large.png", nil)
s.thumbnail(rr, req)
if rr.Code != 415 {
t.Fatalf("thumbnail pixel-limit status=%d want 415", rr.Code)
}
}
func TestAuditThumbnailRejectsDeclaredHugeDimensionsBeforeDecode(t *testing.T) {
root := t.TempDir()
f, err := os.Create(filepath.Join(root, "bomb.png"))
if err != nil {
t.Fatal(err)
}
img := image.NewRGBA(image.Rect(0, 0, 1, 1))
if err := png.Encode(f, img); err != nil {
f.Close()
t.Fatal(err)
}
if err := f.Close(); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(root, "bomb.png"))
if err != nil {
t.Fatal(err)
}
// PNG IHDR starts at byte 16. Replace the 1x1 declaration with a
// deliberately huge image while keeping the file otherwise tiny/valid.
binary.BigEndian.PutUint32(data[16:20], 100000)
binary.BigEndian.PutUint32(data[20:24], 100000)
crc := crc32.ChecksumIEEE(data[12:29])
binary.BigEndian.PutUint32(data[29:33], crc)
if err := os.WriteFile(filepath.Join(root, "bomb.png"), data, 0600); err != nil {
t.Fatal(err)
}
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Read}}, ImagePreview: true, ThumbnailMaxPixels: 40_000_000}
rr := httptest.NewRecorder()
req := httptest.NewRequest("GET", "/chfs/thumbnail?file=bomb.png", nil)
s.thumbnail(rr, req)
if rr.Code != 415 {
t.Fatalf("thumbnail bomb status=%d want 415", rr.Code)
}
}
func TestAuditUploadMaxBytesReaderBeforeMultipartSpool(t *testing.T) {
for _, tc := range []struct {
name string
handler func(*Server, http.ResponseWriter, *http.Request)
}{
{"upload", (*Server).upload},
{"upload2", (*Server).upload2},
} {
t.Run(tc.name, func(t *testing.T) {
root := t.TempDir()
var body bytes.Buffer
mw := multipart.NewWriter(&body)
part, err := mw.CreateFormFile("file", "oversize.bin")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(make([]byte, 1024)); err != nil {
t.Fatal(err)
}
if err := mw.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, "/chfs/"+tc.name+"?folder=", &body)
req.Header.Set("Content-Type", mw.FormDataContentType())
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Guest: &auth.Account{Mask: auth.Write}}, UploadMaxTotalSize: 128}
rr := httptest.NewRecorder()
tc.handler(s, rr, req)
if rr.Code != http.StatusRequestEntityTooLarge {
t.Fatalf("status=%d want 413", rr.Code)
}
})
}
}
+195 -12
View File
@@ -47,6 +47,7 @@ type Server struct {
WebDAVRoot string WebDAVRoot string
WebDAVFS webdav.FileSystem WebDAVFS webdav.FileSystem
Allow string Allow string
TrustedProxies string
MultiRoot bool MultiRoot bool
RemoveMode int RemoveMode int
ImagePreview bool ImagePreview bool
@@ -56,6 +57,14 @@ type Server struct {
Log *opslog.Logger Log *opslog.Logger
UserStore *auth.UserStore UserStore *auth.UserStore
UserHome string UserHome string
ThumbnailMaxSize int64
ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
ThumbnailCache *thumbnailCache ThumbnailCache *thumbnailCache
thumbnailCacheOnce sync.Once thumbnailCacheOnce sync.Once
} }
@@ -138,11 +147,11 @@ func (s *Server) logOp(r *http.Request, action string) {
if a != nil { if a != nil {
user = a.Username user = a.Username
} }
_ = s.Log.Write(clientIPString(r), user, action) _ = s.Log.Write(s.clientIPString(r), user, action)
} }
func clientIPString(r *http.Request) string { func (s *Server) clientIPString(r *http.Request) string {
ip := clientIP(r) ip := s.clientIP(r)
if ip == nil { if ip == nil {
return "-" return "-"
} }
@@ -516,6 +525,10 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) {
return return
} }
p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/") p := strings.Trim(strings.ReplaceAll(raw, "\\", "/"), "/")
if !account(r, s).MaskFor(p).HasRead() {
writeJSONError(w, http.StatusForbidden, "has no privilege")
return
}
_, e := s.Store.Stat(p) _, e := s.Store.Stat(p)
if e != nil { if e != nil {
w.WriteHeader(http.StatusNotFound) w.WriteHeader(http.StatusNotFound)
@@ -639,7 +652,7 @@ func (s *Server) password(w http.ResponseWriter, r *http.Request) {
writeJSONError(w, http.StatusBadRequest, "invalid json") writeJSONError(w, http.StatusBadRequest, "invalid json")
return return
} }
if a.Password != req.CurrentPassword && a.PasswordHash != auth.PasswordHash(req.CurrentPassword) { if a.Password != req.CurrentPassword && !auth.VerifyPasswordHash(a.PasswordHash, req.CurrentPassword) {
writeJSONError(w, http.StatusBadRequest, "current password incorrect") writeJSONError(w, http.StatusBadRequest, "current password incorrect")
return return
} }
@@ -836,8 +849,15 @@ func (s *Server) updatetext(w http.ResponseWriter, r *http.Request) {
writeCreated(w) writeCreated(w)
} }
func (s *Server) upload(w http.ResponseWriter, r *http.Request) { func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
if s.UploadMaxTotalSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, s.UploadMaxTotalSize)
}
if e := r.ParseMultipartForm(64 << 20); e != nil { if e := r.ParseMultipartForm(64 << 20); e != nil {
http.Error(w, e.Error(), 400) if _, ok := e.(*http.MaxBytesError); ok {
http.Error(w, e.Error(), http.StatusRequestEntityTooLarge)
} else {
http.Error(w, e.Error(), http.StatusBadRequest)
}
return return
} }
a := account(r, s) a := account(r, s)
@@ -855,8 +875,24 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
// This prevents duplicate/bad entries from leaving a partial upload. // This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var saved []string var saved []string
var totalUpload int64
var uploadFiles int
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { for _, h := range hs {
uploadFiles++
if s.UploadMaxFiles > 0 && uploadFiles > s.UploadMaxFiles {
http.Error(w, "upload file count limit exceeded", http.StatusRequestEntityTooLarge)
return
}
if s.UploadMaxSize > 0 && h.Size > s.UploadMaxSize {
http.Error(w, "upload file too large", http.StatusRequestEntityTooLarge)
return
}
totalUpload += h.Size
if s.UploadMaxTotalSize > 0 && totalUpload > s.UploadMaxTotalSize {
http.Error(w, "upload total size limit exceeded", http.StatusRequestEntityTooLarge)
return
}
name := filepath.ToSlash(h.Filename) name := filepath.ToSlash(h.Filename)
if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) { if name == "" || strings.HasPrefix(name, "/") || unsafePath(name) {
http.Error(w, "invalid filename", 400) http.Error(w, "invalid filename", 400)
@@ -881,7 +917,7 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { for _, h := range hs {
target := path.Join(dir, filepath.ToSlash(h.Filename)) target := path.Join(dir, filepath.ToSlash(h.Filename))
if e := savePart(s.Store, target, h); e != nil { if e := savePart(s.Store, target, h, s.UploadMaxSize); e != nil {
for _, previous := range saved { for _, previous := range saved {
_ = s.Store.Remove(previous) _ = s.Store.Remove(previous)
} }
@@ -908,7 +944,7 @@ func createExclusive(st storage.Storage, target string) (*os.File, error) {
return st.Create(target) return st.Create(target)
} }
func savePart(st storage.Storage, target string, h *multipart.FileHeader) error { func savePart(st storage.Storage, target string, h *multipart.FileHeader, maxSize int64) error {
in, e := h.Open() in, e := h.Open()
if e != nil { if e != nil {
return e return e
@@ -919,8 +955,18 @@ func savePart(st storage.Storage, target string, h *multipart.FileHeader) error
return e return e
} }
copyErr := func() error { copyErr := func() error {
_, err := io.Copy(f, in) var src io.Reader = in
if maxSize > 0 {
src = io.LimitReader(in, maxSize+1)
}
n, err := io.Copy(f, src)
if err != nil {
return err return err
}
if maxSize > 0 && n > maxSize {
return fmt.Errorf("upload file too large")
}
return nil
}() }()
closeErr := f.Close() closeErr := f.Close()
if copyErr != nil { if copyErr != nil {
@@ -935,6 +981,9 @@ func savePart(st storage.Storage, target string, h *multipart.FileHeader) error
} }
func (s *Server) upload2(w http.ResponseWriter, r *http.Request) { func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
if s.UploadMaxTotalSize > 0 {
r.Body = http.MaxBytesReader(w, r.Body, s.UploadMaxTotalSize)
}
a := account(r, s) a := account(r, s)
rawDir := r.URL.Query().Get("folder") rawDir := r.URL.Query().Get("folder")
if unsafePath(rawDir) { if unsafePath(rawDir) {
@@ -947,14 +996,34 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
return return
} }
if err := r.ParseMultipartForm(128 << 20); err != nil { if err := r.ParseMultipartForm(128 << 20); err != nil {
http.Error(w, err.Error(), 400) if _, ok := err.(*http.MaxBytesError); ok {
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge)
} else {
http.Error(w, err.Error(), http.StatusBadRequest)
}
return return
} }
// Preflight the complete multipart batch before creating anything. // Preflight the complete multipart batch before creating anything.
// This prevents duplicate/bad entries from leaving a partial upload. // This prevents duplicate/bad entries from leaving a partial upload.
seen := make(map[string]struct{}) seen := make(map[string]struct{})
var totalUpload int64
var uploadFiles int
for _, hs := range r.MultipartForm.File { for _, hs := range r.MultipartForm.File {
for _, h := range hs { for _, h := range hs {
uploadFiles++
if s.UploadMaxFiles > 0 && uploadFiles > s.UploadMaxFiles {
http.Error(w, "upload file count limit exceeded", http.StatusRequestEntityTooLarge)
return
}
if s.UploadMaxSize > 0 && h.Size > s.UploadMaxSize {
http.Error(w, "upload file too large", http.StatusRequestEntityTooLarge)
return
}
totalUpload += h.Size
if s.UploadMaxTotalSize > 0 && totalUpload > s.UploadMaxTotalSize {
http.Error(w, "upload total size limit exceeded", http.StatusRequestEntityTooLarge)
return
}
name := filepath.ToSlash(h.Filename) name := filepath.ToSlash(h.Filename)
name = strings.TrimPrefix(name, "/") name = strings.TrimPrefix(name, "/")
if name == "" || unsafePath(name) { if name == "" || unsafePath(name) {
@@ -981,7 +1050,7 @@ func (s *Server) upload2(w http.ResponseWriter, r *http.Request) {
for _, h := range hs { for _, h := range hs {
name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/") name := strings.TrimPrefix(filepath.ToSlash(h.Filename), "/")
target := path.Join(dir, name) target := path.Join(dir, name)
if err := savePart(s.Store, target, h); err != nil { if err := savePart(s.Store, target, h, s.UploadMaxSize); err != nil {
for _, previous := range saved { for _, previous := range saved {
_ = s.Store.Remove(previous) _ = s.Store.Remove(previous)
} }
@@ -1015,6 +1084,11 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
} }
needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str"))) needle := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("str")))
out := make([]searchFileInfo, 0) out := make([]searchFileInfo, 0)
maxResults := s.SearchMaxResults
if maxResults < 0 {
maxResults = 0
}
truncated := false
var walk func(string) error var walk func(string) error
walk = func(dir string) error { walk = func(dir string) error {
items, err := s.Store.List(dir) items, err := s.Store.List(dir)
@@ -1022,6 +1096,10 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
return err return err
} }
for _, fi := range items { for _, fi := range items {
if maxResults > 0 && len(out) >= maxResults {
truncated = true
return nil
}
p := path.Join(dir, fi.Name()) p := path.Join(dir, fi.Name())
if !a.MaskFor(p).HasRead() { if !a.MaskFor(p).HasRead() {
continue continue
@@ -1041,7 +1119,7 @@ func (s *Server) search(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), 500) http.Error(w, err.Error(), 500)
return return
} }
writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out}) writeJSON(w, map[string]any{"virtualRoot": 0, "currentMask": permString(rootMask), "files": out, "truncated": truncated})
} }
func sharedPath(parts ...string) string { func sharedPath(parts ...string) string {
@@ -1183,6 +1261,10 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r) http.NotFound(w, r)
return return
} }
if s.ThumbnailMaxSize > 0 && fi.Size() > s.ThumbnailMaxSize {
writeJSONError(w, http.StatusRequestEntityTooLarge, "thumbnail file too large")
return
}
key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()} key := thumbnailCacheKey{Path: file, Size: fi.Size(), ModTime: fi.ModTime().UnixNano()}
s.thumbnailCacheOnce.Do(func() { s.thumbnailCacheOnce.Do(func() {
if s.ThumbnailCache == nil { if s.ThumbnailCache == nil {
@@ -1190,6 +1272,16 @@ func (s *Server) thumbnail(w http.ResponseWriter, r *http.Request) {
} }
}) })
data, err := s.ThumbnailCache.Do(key, func() ([]byte, error) { data, err := s.ThumbnailCache.Do(key, func() ([]byte, error) {
config, _, err := image.DecodeConfig(f)
if err != nil {
return nil, err
}
if s.ThumbnailMaxPixels > 0 && int64(config.Width)*int64(config.Height) > s.ThumbnailMaxPixels {
return nil, fmt.Errorf("thumbnail dimensions too large")
}
if _, err := f.Seek(0, io.SeekStart); err != nil {
return nil, err
}
src, _, err := image.Decode(f) src, _, err := image.Decode(f)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -1287,6 +1379,62 @@ func (s *Server) qrcode(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(data) _, _ = w.Write(data)
} }
func (s *Server) zipStats(a *auth.Account, roots []string) (int64, int, error) {
var total int64
files := 0
seen := map[string]bool{}
var walk func(string) error
walk = func(p string) error {
if seen[p] {
return nil
}
seen[p] = true
fi, err := s.Store.Stat(p)
if err != nil {
return err
}
if !a.MaskFor(p).HasRead() {
return nil
}
if !fi.IsDir() {
files++
total += fi.Size()
return nil
}
items, err := s.Store.List(p)
if err != nil {
return err
}
for _, it := range items {
child := path.Join(p, it.Name())
if err := walk(child); err != nil {
return err
}
}
return nil
}
for _, root := range roots {
if err := walk(root); err != nil {
return 0, 0, err
}
}
return total, files, nil
}
func (s *Server) checkZipLimits(a *auth.Account, roots []string) error {
total, files, err := s.zipStats(a, roots)
if err != nil {
return err
}
if s.ZipMaxFiles > 0 && files > s.ZipMaxFiles {
return fmt.Errorf("zip file count limit exceeded")
}
if s.ZipMaxSize > 0 && total > s.ZipMaxSize {
return fmt.Errorf("zip size limit exceeded")
}
return nil
}
func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) { func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) {
a := account(r, s) a := account(r, s)
paths := r.URL.Query()["path"] paths := r.URL.Query()["path"]
@@ -1312,6 +1460,10 @@ func (s *Server) downloadSelectedZip(w http.ResponseWriter, r *http.Request) {
return return
} }
} }
if err := s.checkZipLimits(a, paths); err != nil {
writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
return
}
w.Header().Set("Content-Type", "application/zip") w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`) w.Header().Set("Content-Disposition", `attachment; filename="download.zip"`)
zw := zip.NewWriter(w) zw := zip.NewWriter(w)
@@ -1403,6 +1555,10 @@ func (s *Server) downloadDir(w http.ResponseWriter, r *http.Request) {
} }
} }
} }
if err := s.checkZipLimits(a, []string{p}); err != nil {
writeJSONError(w, http.StatusRequestEntityTooLarge, err.Error())
return
}
name := fi.Name() name := fi.Name()
if name == "." || name == "" { if name == "." || name == "" {
name = "download" name = "download"
@@ -2064,7 +2220,7 @@ func (f *webdavAuthFS) checkTree(ctx context.Context, name string, p auth.PermMa
func (s *Server) ipFilter(next http.Handler) http.Handler { func (s *Server) ipFilter(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.Allow != "" && !allowedIP(s.Allow, clientIP(r)) { if s.Allow != "" && !allowedIP(s.Allow, s.clientIP(r)) {
http.Error(w, "forbidden", http.StatusForbidden) http.Error(w, "forbidden", http.StatusForbidden)
return return
} }
@@ -2072,6 +2228,33 @@ func (s *Server) ipFilter(next http.Handler) http.Handler {
}) })
} }
func (s *Server) clientIP(r *http.Request) net.IP {
host, _, _ := net.SplitHostPort(r.RemoteAddr)
remote := net.ParseIP(host)
if remote == nil {
remote = net.ParseIP(r.RemoteAddr)
}
if remote == nil || s.TrustedProxies == "" || !allowedIP(s.TrustedProxies, remote) {
return remote
}
// Only a trusted proxy may supply the forwarding chain. Walk right-to-left
// and return the first address that is not itself a trusted proxy.
parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
for i := len(parts) - 1; i >= 0; i-- {
ip := net.ParseIP(strings.TrimSpace(parts[i]))
if ip == nil {
continue
}
if !allowedIP(s.TrustedProxies, ip) {
return ip
}
}
if v := net.ParseIP(strings.TrimSpace(r.Header.Get("X-Real-IP"))); v != nil && !allowedIP(s.TrustedProxies, v) {
return v
}
return remote
}
func clientIP(r *http.Request) net.IP { func clientIP(r *http.Request) net.IP {
host, _, err := net.SplitHostPort(r.RemoteAddr) host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil { if err == nil {
File diff suppressed because one or more lines are too long
+20 -2
View File
@@ -8,6 +8,7 @@ import (
"time" "time"
jwt "github.com/golang-jwt/jwt/v4" jwt "github.com/golang-jwt/jwt/v4"
"golang.org/x/crypto/bcrypt"
) )
type PermMask uint8 type PermMask uint8
@@ -86,17 +87,34 @@ func ParseRules(values []string) (*Manager, error) {
} }
func (m *Manager) Authenticate(user, pwd string) *Account { func (m *Manager) Authenticate(user, pwd string) *Account {
if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") && (a.Password == pwd || a.PasswordHash == PasswordHash(pwd)) { if a, ok := m.Accounts[user]; ok && (a.Enabled || a.PasswordHash == "") {
if a.Password == pwd || VerifyPasswordHash(a.PasswordHash, pwd) {
return a return a
} }
}
if user == "" && pwd == "" { if user == "" && pwd == "" {
return m.Guest return m.Guest
} }
return nil return nil
} }
func PasswordHash(password string) string { func PasswordHash(password string) string {
h, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return ""
}
return string(h)
}
func VerifyPasswordHash(stored, password string) bool {
if stored == "" {
return false
}
if strings.HasPrefix(stored, "$2a$") || strings.HasPrefix(stored, "$2b$") || strings.HasPrefix(stored, "$2y$") {
return bcrypt.CompareHashAndPassword([]byte(stored), []byte(password)) == nil
}
// Legacy unsalted SHA-256 hashes remain readable for one migration cycle.
h := sha256.Sum256([]byte(password)) h := sha256.Sum256([]byte(password))
return hex.EncodeToString(h[:]) return hex.EncodeToString(h[:]) == stored
} }
func (a *Account) MaskFor(path string) PermMask { func (a *Account) MaskFor(path string) PermMask {
+31
View File
@@ -0,0 +1,31 @@
package auth
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
func TestPasswordHashUsesBcryptAndVerifies(t *testing.T) {
hash := PasswordHash("correct horse battery staple")
if len(hash) < 4 || (hash[0:4] != "$2a$" && hash[0:4] != "$2b$") {
t.Fatalf("hash=%q is not bcrypt", hash)
}
if !VerifyPasswordHash(hash, "correct horse battery staple") {
t.Fatal("bcrypt password did not verify")
}
if VerifyPasswordHash(hash, "wrong") {
t.Fatal("wrong password verified")
}
}
func TestLegacySHA256PasswordStillVerifies(t *testing.T) {
sum := sha256.Sum256([]byte("legacy-pass"))
hash := hex.EncodeToString(sum[:])
if !VerifyPasswordHash(hash, "legacy-pass") {
t.Fatal("legacy hash did not verify")
}
if VerifyPasswordHash(hash, "wrong") {
t.Fatal("wrong legacy password verified")
}
}
+44 -1
View File
@@ -23,6 +23,7 @@ type Config struct {
Path []string Path []string
Port int Port int
Allow string Allow string
TrustedProxies string
Rules []string Rules []string
Log string Log string
FileRemove int FileRemove int
@@ -34,13 +35,21 @@ type Config struct {
HTMLNotice string HTMLNotice string
UserFile string UserFile string
UserHome string UserHome string
ThumbnailMaxSize int64
ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
SSLCert string SSLCert string
SSLKey string SSLKey string
Accounts []AccountConfig Accounts []AccountConfig
} }
func Default() Config { func Default() Config {
return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users"} return Config{Path: []string{"."}, Port: 80, FileRemove: 1, ImagePreview: true, FolderDownload: "enable", SessionTimeout: 30, UserHome: "users", ThumbnailMaxSize: 32 << 20, ThumbnailMaxPixels: 40_000_000, ZipMaxSize: 2 << 30, ZipMaxFiles: 5000, UploadMaxSize: 2 << 30, UploadMaxTotalSize: 4 << 30, UploadMaxFiles: 100, SearchMaxResults: 500}
} }
func Load(path string) (Config, error) { func Load(path string) (Config, error) {
@@ -101,6 +110,8 @@ func Load(path string) (Config, error) {
} }
case "allow": case "allow":
c.Allow = val c.Allow = val
case "trusted-proxies":
c.TrustedProxies = val
case "rule": case "rule":
if val != "" { if val != "" {
c.Rules = append(c.Rules, val) c.Rules = append(c.Rules, val)
@@ -133,6 +144,38 @@ func Load(path string) (Config, error) {
c.UserFile = val c.UserFile = val
case "user.home": case "user.home":
c.UserHome = strings.Trim(val, "/") c.UserHome = strings.Trim(val, "/")
case "thumbnail.max-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.ThumbnailMaxSize = n
}
case "thumbnail.max-pixels":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.ThumbnailMaxPixels = n
}
case "zip.max-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.ZipMaxSize = n
}
case "zip.max-files":
if n, e := strconv.Atoi(val); e == nil && n >= 0 {
c.ZipMaxFiles = n
}
case "upload.max-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.UploadMaxSize = n
}
case "upload.max-total-size":
if n, e := strconv.ParseInt(val, 10, 64); e == nil && n >= 0 {
c.UploadMaxTotalSize = n
}
case "upload.max-files":
if n, e := strconv.Atoi(val); e == nil && n >= 0 {
c.UploadMaxFiles = n
}
case "search.max-results":
if n, e := strconv.Atoi(val); e == nil && n >= 0 {
c.SearchMaxResults = n
}
case "ssl.cert": case "ssl.cert":
c.SSLCert = val c.SSLCert = val
case "ssl.key": case "ssl.key":
+43 -1
View File
@@ -150,7 +150,49 @@ func (s Local) Trash(path string) error {
if e != nil { if e != nil {
return e return e
} }
return wastebasket.Trash(p) if err := wastebasket.Trash(p); err != nil {
return err
}
_ = cleanupSystemTrash(30 * 24 * time.Hour)
return nil
}
func cleanupSystemTrash(maxAge time.Duration) error {
base := os.Getenv("XDG_DATA_HOME")
if base == "" {
home, err := os.UserHomeDir()
if err != nil {
return err
}
base = filepath.Join(home, ".local", "share")
}
root := filepath.Join(base, "Trash")
filesDir := filepath.Join(root, "files")
infoDir := filepath.Join(root, "info")
now := time.Now()
entries, err := os.ReadDir(infoDir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".trashinfo" {
continue
}
info, err := entry.Info()
if err != nil {
continue
}
if now.Sub(info.ModTime()) <= maxAge {
continue
}
name := strings.TrimSuffix(entry.Name(), ".trashinfo")
_ = os.Remove(filepath.Join(infoDir, entry.Name()))
_ = os.RemoveAll(filepath.Join(filesDir, name))
}
return nil
} }
func (s Local) MoveToTrash(path, trashRoot string) error { func (s Local) MoveToTrash(path, trashRoot string) error {
+42
View File
@@ -0,0 +1,42 @@
package storage
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestCleanupSystemTrashRemovesOldEntries(t *testing.T) {
data := t.TempDir()
t.Setenv("XDG_DATA_HOME", data)
files := filepath.Join(data, "Trash", "files")
info := filepath.Join(data, "Trash", "info")
if err := os.MkdirAll(files, 0755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(info, 0755); err != nil {
t.Fatal(err)
}
oldInfo := filepath.Join(info, "old.txt.trashinfo")
oldFile := filepath.Join(files, "old.txt")
if err := os.WriteFile(oldInfo, []byte("[Trash Info]\n"), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(oldFile, []byte("old"), 0600); err != nil {
t.Fatal(err)
}
old := time.Now().Add(-31 * 24 * time.Hour)
if err := os.Chtimes(oldInfo, old, old); err != nil {
t.Fatal(err)
}
if err := cleanupSystemTrash(30 * 24 * time.Hour); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(oldInfo); !os.IsNotExist(err) {
t.Fatalf("old info remains: %v", err)
}
if _, err := os.Stat(oldFile); !os.IsNotExist(err) {
t.Fatalf("old file remains: %v", err)
}
}