Compare commits

..
23 Commits
Author SHA1 Message Date
kory 4dafcff680 docs: document security limits and deployment 2026-10-03 02:30:21 +08:00
kory 5d7e8f1ec0 security: close thumbnail and upload quota bypasses 2026-10-03 02:15:04 +08:00
kory 56b05b11d9 security: remediate review B findings 2026-10-03 01:37:02 +08:00
OpenClaw Agent ca4d54d5b3 ui: restore contextual QR file actions 2026-09-23 06:54:16 +08:00
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
17 changed files with 1283 additions and 134 deletions
+5
View File
@@ -0,0 +1,5 @@
# Local build artifacts
bin/
# Local/debug runtime configuration
chfs.ini
+46
View File
@@ -10,8 +10,15 @@ CHFS-NG 是基于 CHFS 思路重构的轻量级、自托管 Web 文件管理器
- 上传队列、进度、取消与重试,支持文件夹拖拽上传 - 上传队列、进度、取消与重试,支持文件夹拖拽上传
- 动态用户管理与独立个人私有目录 - 动态用户管理与独立个人私有目录
- 多根目录与目录级读写/删除权限 - 多根目录与目录级读写/删除权限
- 完善的权限体系:支持访客/只读/读写/删除动态权限控制及安全防御
- 缩略图本地缓存与流式预览优化,低内存开销
- 桌面端(1080P/2K/4K)与移动端自适应布局,视觉规整与操作闭环
- 分享、二维码移动传输、WebDAV - 分享、二维码移动传输、WebDAV
- 健康检查:/healthz - 健康检查:/healthz
- 资源耗尽防护:上传、ZIP 打包、搜索与缩略图均有默认资源上限
- 缩略图先通过 DecodeConfig 校验像素面积,再执行完整解码,避免声明尺寸型图片导致内存耗尽
- 上传入口在 multipart 解析前使用 MaxBytesReader 限制请求体总量
- 反向代理场景支持可信代理配置,仅对可信来源解析 X-Forwarded-For / X-Real-IP
- 构建产物使用 -s -w,适合轻量 NAS - 构建产物使用 -s -w,适合轻量 NAS
## 快速开始 ## 快速开始
@@ -145,5 +152,44 @@ 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
- **前端交互与弹窗修复**:修复 `ask()` 弹窗中事件监听与回车提交逻辑,支持新建文件夹/新建文本顺畅操作。
- **桌面端大屏视口适配**:放宽主容器宽度至 1600px 并动态对齐顶部导航栏边距,补全表格操作列,消除 PC 端右侧大屏留白断层。
- **移动端与跨浏览器表格对齐**:固化列表各列宽度(`table-layout: fixed`),优化移动端触屏操作栏显示。
- **访客权限与交互收紧**:统一未登录访客的多选及操作限制,修复危险操作按钮层叠样式。
- **缩略图缓存机制**:引入生成缓存与内存保护机制,加快大图加载并降低宿主机内存消耗。
## License ## License
本项目许可证以仓库中的 LICENSE 文件为准。 本项目许可证以仓库中的 LICENSE 文件为准。
+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)
}
})
}
}
+313 -54
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,36 @@ 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 TrustedProxies string
RemoveMode int MultiRoot bool
ImagePreview bool RemoveMode int
FolderDownload string ImagePreview bool
HTMLTitle string FolderDownload string
HTMLNotice string HTMLTitle string
Log *opslog.Logger HTMLNotice string
UserStore *auth.UserStore Log *opslog.Logger
UserHome string UserStore *auth.UserStore
UserHome string
ThumbnailMaxSize int64
ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
ThumbnailCache *thumbnailCache
thumbnailCacheOnce sync.Once
} }
type fileInfo struct { type fileInfo struct {
Name string `json:"name"` Name string `json:"name"`
@@ -70,6 +82,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 +108,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) {
@@ -133,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 "-"
} }
@@ -511,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)
@@ -563,7 +581,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 {
@@ -627,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
} }
@@ -824,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)
@@ -843,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)
@@ -869,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)
} }
@@ -896,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
@@ -907,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
return err if maxSize > 0 {
src = io.LimitReader(in, maxSize+1)
}
n, err := io.Copy(f, src)
if err != nil {
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 {
@@ -923,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) {
@@ -935,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) {
@@ -969,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)
} }
@@ -1003,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)
@@ -1010,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
@@ -1029,7 +1119,19 @@ 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 {
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) {
@@ -1054,33 +1156,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 +1201,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 +1213,93 @@ 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
}
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()}
s.thumbnailCacheOnce.Do(func() {
if s.ThumbnailCache == nil {
s.ThumbnailCache = newThumbnailCache()
}
})
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)
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) {
@@ -1211,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"]
@@ -1236,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)
@@ -1327,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"
@@ -1988,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
} }
@@ -1996,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 {
+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
+21 -3
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,8 +87,10 @@ 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 == "") {
return a if a.Password == pwd || VerifyPasswordHash(a.PasswordHash, pwd) {
return a
}
} }
if user == "" && pwd == "" { if user == "" && pwd == "" {
return m.Guest return m.Guest
@@ -95,8 +98,23 @@ func (m *Manager) Authenticate(user, pwd string) *Account {
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")
}
}
+61 -18
View File
@@ -20,27 +20,36 @@ type AccountConfig struct {
} }
type Config struct { type Config struct {
Path []string Path []string
Port int Port int
Allow string Allow string
Rules []string TrustedProxies string
Log string Rules []string
FileRemove int Log string
ImagePreview bool FileRemove int
FolderDownload string ImagePreview bool
SessionTimeout int FolderDownload string
JWTSecret string SessionTimeout int
HTMLTitle string JWTSecret string
HTMLNotice string HTMLTitle string
UserFile string HTMLNotice string
UserHome string UserFile string
SSLCert string UserHome string
SSLKey string ThumbnailMaxSize int64
Accounts []AccountConfig ThumbnailMaxPixels int64
ZipMaxSize int64
ZipMaxFiles int
UploadMaxSize int64
UploadMaxTotalSize int64
UploadMaxFiles int
SearchMaxResults int
SSLCert string
SSLKey string
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)
}
}