Compare commits
27
Commits
beeee8191b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dafcff680 | ||
|
|
5d7e8f1ec0 | ||
|
|
56b05b11d9 | ||
|
|
ca4d54d5b3 | ||
|
|
c4dd76be56 | ||
|
|
7d263c916a | ||
|
|
a1caeff746 | ||
|
|
ae6de3e047 | ||
|
|
7d3a5c13fd | ||
|
|
e1e8baccc9 | ||
|
|
bc45dac945 | ||
|
|
d3c9acf674 | ||
|
|
9af81fdc8d | ||
|
|
a2113f831d | ||
|
|
e6806de5f6 | ||
|
|
f799104fb2 | ||
|
|
e3fef60d60 | ||
|
|
6b86eb4185 | ||
|
|
11a3a2ef4c | ||
|
|
59aef8fed8 | ||
|
|
2690888a21 | ||
|
|
d8c938a0c8 | ||
|
|
73c05ef112 | ||
|
|
7b2c5f444b | ||
|
|
f8b5b585c9 | ||
|
|
c37161db31 | ||
|
|
2c8531d9b9 |
@@ -0,0 +1,5 @@
|
|||||||
|
# Local build artifacts
|
||||||
|
bin/
|
||||||
|
|
||||||
|
# Local/debug runtime configuration
|
||||||
|
chfs.ini
|
||||||
@@ -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 文件为准。
|
||||||
|
|||||||
+24
-1
@@ -1,6 +1,8 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
"flag"
|
"flag"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -120,10 +122,31 @@ func main() {
|
|||||||
} else if !filepath.IsAbs(userFile) {
|
} else if !filepath.IsAbs(userFile) {
|
||||||
userFile = filepath.Join(root, userFile)
|
userFile = filepath.Join(root, userFile)
|
||||||
}
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(userFile), 0700); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
users := &auth.UserStore{Path: userFile}
|
users := &auth.UserStore{Path: userFile}
|
||||||
if err := users.Load(mgr, cfg.UserHome); err != nil {
|
if err := users.Load(mgr, cfg.UserHome); err != nil {
|
||||||
log.Fatal(err)
|
log.Fatal(err)
|
||||||
}
|
}
|
||||||
|
jwtSecret := cfg.JWTSecret
|
||||||
|
if jwtSecret == "" {
|
||||||
|
// Keep sessions valid across process restarts. A random in-memory secret
|
||||||
|
// would invalidate every login whenever the service is restarted.
|
||||||
|
secretPath := userFile + ".jwt-secret"
|
||||||
|
if saved, err := os.ReadFile(secretPath); err == nil && len(strings.TrimSpace(string(saved))) >= 32 {
|
||||||
|
jwtSecret = strings.TrimSpace(string(saved))
|
||||||
|
} else {
|
||||||
|
buf := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(buf); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
jwtSecret = hex.EncodeToString(buf)
|
||||||
|
if err := os.WriteFile(secretPath, []byte(jwtSecret+"\n"), 0600); err != nil {
|
||||||
|
log.Printf("warning: could not persist JWT secret at %s: %v", secretPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
for _, a := range mgr.Accounts {
|
for _, a := range mgr.Accounts {
|
||||||
if a.Home != "" {
|
if a.Home != "" {
|
||||||
if err := store.MkdirAll(a.Home); err != nil {
|
if err := store.MkdirAll(a.Home); err != nil {
|
||||||
@@ -131,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, 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 != "" {
|
||||||
|
|||||||
@@ -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/上传/回收站/搜索的资源限制(中)。
|
||||||
|
修完阻断项和高优先级后可认为达到生产安全标准。
|
||||||
@@ -5,7 +5,8 @@ image.preview=true
|
|||||||
file.remove=3
|
file.remove=3
|
||||||
|
|
||||||
Known additional keys from binary/config ecosystem:
|
Known additional keys from binary/config ecosystem:
|
||||||
session.timeout
|
session.timeout # login session lifetime in minutes; default 30
|
||||||
|
jwt.secret # optional signing secret; if omitted a random secret is generated per process
|
||||||
folder.download
|
folder.download
|
||||||
folder.leaf.download
|
folder.leaf.download
|
||||||
rule.default
|
rule.default
|
||||||
|
|||||||
@@ -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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,9 @@ func TestRootAndAPIDoc(t *testing.T) {
|
|||||||
if r.Code != 200 || !strings.Contains(r.Body.String(), "<title>My Files</title>") || !strings.Contains(r.Body.String(), "<strong>Hello</strong>") {
|
if r.Code != 200 || !strings.Contains(r.Body.String(), "<title>My Files</title>") || !strings.Contains(r.Body.String(), "<strong>Hello</strong>") {
|
||||||
t.Fatalf("root: %d %s", r.Code, r.Body.String())
|
t.Fatalf("root: %d %s", r.Code, r.Body.String())
|
||||||
}
|
}
|
||||||
|
if got := r.Header().Get("Cache-Control"); got != "no-store" {
|
||||||
|
t.Fatalf("root cache-control=%q", got)
|
||||||
|
}
|
||||||
r = httptest.NewRecorder()
|
r = httptest.NewRecorder()
|
||||||
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
|
h.ServeHTTP(r, httptest.NewRequest(http.MethodGet, "/asset/api.html", nil))
|
||||||
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
|
if r.Code != 200 || !strings.Contains(r.Body.String(), "/chfs/files") {
|
||||||
|
|||||||
+336
-70
@@ -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,24 +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
|
||||||
WebDAVRoot string
|
JWTSecret string
|
||||||
WebDAVFS webdav.FileSystem
|
WebDAVRoot string
|
||||||
Allow string
|
WebDAVFS webdav.FileSystem
|
||||||
MultiRoot bool
|
Allow string
|
||||||
RemoveMode int
|
TrustedProxies string
|
||||||
ImagePreview bool
|
MultiRoot bool
|
||||||
FolderDownload string
|
RemoveMode int
|
||||||
HTMLTitle string
|
ImagePreview bool
|
||||||
HTMLNotice string
|
FolderDownload string
|
||||||
Log *opslog.Logger
|
HTMLTitle string
|
||||||
UserStore *auth.UserStore
|
HTMLNotice string
|
||||||
UserHome string
|
Log *opslog.Logger
|
||||||
|
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"`
|
||||||
@@ -69,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)
|
||||||
@@ -94,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) {
|
||||||
@@ -113,7 +128,7 @@ func account(r *http.Request, s *Server) *auth.Account {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return s.Auth.Guest
|
return s.Auth.Guest
|
||||||
}
|
}
|
||||||
username, err := auth.GetAccFromToken(c.Value)
|
username, err := auth.GetAccFromToken(c.Value, s.JWTSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return s.Auth.Guest
|
return s.Auth.Guest
|
||||||
}
|
}
|
||||||
@@ -132,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 "-"
|
||||||
}
|
}
|
||||||
@@ -145,11 +160,20 @@ func clientIPString(r *http.Request) string {
|
|||||||
|
|
||||||
func writeJSON(w http.ResponseWriter, v any) {
|
func writeJSON(w http.ResponseWriter, v any) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Vary", "Cookie")
|
||||||
json.NewEncoder(w).Encode(v)
|
json.NewEncoder(w).Encode(v)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func noStore(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Vary", "Cookie")
|
||||||
|
}
|
||||||
|
|
||||||
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
func writeJSONError(w http.ResponseWriter, status int, message string) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
|
w.Header().Set("Vary", "Cookie")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||||
}
|
}
|
||||||
@@ -501,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)
|
||||||
@@ -510,19 +538,10 @@ func (s *Server) exist(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) session(w http.ResponseWriter, r *http.Request) {
|
||||||
|
noStore(w)
|
||||||
if r.Method == http.MethodDelete {
|
if r.Method == http.MethodDelete {
|
||||||
jwtValue, _ := r.Cookie("JWT")
|
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), HttpOnly: true, SameSite: http.SameSiteLaxMode})
|
||||||
userValue, _ := r.Cookie("user")
|
http.SetCookie(w, &http.Cookie{Name: "user", Value: "", Path: "/", MaxAge: -1, Expires: time.Unix(1, 0), SameSite: http.SameSiteLaxMode})
|
||||||
jwt := ""
|
|
||||||
user := ""
|
|
||||||
if jwtValue != nil {
|
|
||||||
jwt = jwtValue.Value
|
|
||||||
}
|
|
||||||
if userValue != nil {
|
|
||||||
user = userValue.Value
|
|
||||||
}
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: jwt, Path: "/", MaxAge: -1})
|
|
||||||
http.SetCookie(w, &http.Cookie{Name: "user", Value: user, Path: "/", MaxAge: -1})
|
|
||||||
w.WriteHeader(http.StatusNoContent)
|
w.WriteHeader(http.StatusNoContent)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -540,14 +559,19 @@ func (s *Server) session(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSONError(w, http.StatusNotFound, "user or password is wrong")
|
writeJSONError(w, http.StatusNotFound, "user or password is wrong")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
token, err := auth.NewToken(a.Username, s.SessionTimeout)
|
timeout := s.SessionTimeout
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30
|
||||||
|
}
|
||||||
|
token, err := auth.NewToken(a.Username, timeout, s.JWTSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeJSONError(w, http.StatusInternalServerError, "failed to create token")
|
writeJSONError(w, http.StatusInternalServerError, "failed to create token")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expires := time.Now().Add(365 * 24 * time.Hour)
|
expires := time.Now().Add(time.Duration(timeout) * time.Minute)
|
||||||
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", Expires: expires, HttpOnly: true})
|
secure := r.TLS != nil
|
||||||
http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", Expires: expires})
|
http.SetCookie(w, &http.Cookie{Name: "JWT", Value: token, Path: "/", MaxAge: timeout * 60, Expires: expires, HttpOnly: true, Secure: secure, SameSite: http.SameSiteLaxMode})
|
||||||
|
http.SetCookie(w, &http.Cookie{Name: "user", Value: a.Username, Path: "/", MaxAge: timeout * 60, Expires: expires, Secure: secure, SameSite: http.SameSiteLaxMode})
|
||||||
w.WriteHeader(http.StatusCreated)
|
w.WriteHeader(http.StatusCreated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -557,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 {
|
||||||
@@ -621,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
|
||||||
}
|
}
|
||||||
@@ -818,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)
|
||||||
@@ -837,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)
|
||||||
@@ -863,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)
|
||||||
}
|
}
|
||||||
@@ -890,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
|
||||||
@@ -901,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 {
|
||||||
@@ -917,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) {
|
||||||
@@ -929,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) {
|
||||||
@@ -963,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)
|
||||||
}
|
}
|
||||||
@@ -997,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)
|
||||||
@@ -1004,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
|
||||||
@@ -1023,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) {
|
||||||
@@ -1048,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)
|
||||||
@@ -1083,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1095,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) {
|
||||||
@@ -1133,6 +1307,7 @@ func (s *Server) fileHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.NotFound(w, r)
|
http.NotFound(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
w.Header().Set("Cache-Control", "no-store")
|
||||||
title := s.HTMLTitle
|
title := s.HTMLTitle
|
||||||
if title == "" {
|
if title == "" {
|
||||||
title = "CuteHttpFileServer"
|
title = "CuteHttpFileServer"
|
||||||
@@ -1204,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"]
|
||||||
@@ -1229,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)
|
||||||
@@ -1320,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"
|
||||||
@@ -1981,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
|
||||||
}
|
}
|
||||||
@@ -1989,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 {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"chfs-ng/internal/auth"
|
"chfs-ng/internal/auth"
|
||||||
@@ -30,7 +31,7 @@ func TestSharedRoutesMatchCHFS(t *testing.T) {
|
|||||||
r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil)
|
r = httptest.NewRequest(http.MethodGet, "/chfs/shared/hello.txt", nil)
|
||||||
w = httptest.NewRecorder()
|
w = httptest.NewRecorder()
|
||||||
s.Handler().ServeHTTP(w, r)
|
s.Handler().ServeHTTP(w, r)
|
||||||
if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment;filename="hello.txt"` {
|
if w.Code != http.StatusOK || w.Header().Get("Content-Disposition") != `attachment; filename=hello.txt` {
|
||||||
t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition"))
|
t.Fatalf("shared file: status=%d disposition=%q", w.Code, w.Header().Get("Content-Disposition"))
|
||||||
}
|
}
|
||||||
body, _ := io.ReadAll(w.Result().Body)
|
body, _ := io.ReadAll(w.Result().Body)
|
||||||
@@ -39,6 +40,66 @@ func TestSharedRoutesMatchCHFS(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestSharedGuestFlowAndSafeURLs(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "docs"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "private"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "docs", "你好 #?.txt"), []byte("shared"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "private", "secret.txt"), []byte("secret"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := &Server{Store: storage.Local{Root: root}, Auth: &auth.Manager{Accounts: map[string]*auth.Account{}, Guest: &auth.Account{
|
||||||
|
Mask: auth.Read,
|
||||||
|
Rules: []auth.PathRule{{Dir: "private", Deny: true}},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, url string
|
||||||
|
want int
|
||||||
|
}{
|
||||||
|
{"guest file", "/chfs/shared/docs/%E4%BD%A0%E5%A5%BD%20%23%3F.txt", http.StatusOK},
|
||||||
|
{"denied file", "/chfs/shared/private/secret.txt", http.StatusNotFound},
|
||||||
|
{"traversal", "/chfs/shared/../private/secret.txt", http.StatusNotFound},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest(http.MethodGet, tc.url, nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
if tc.name == "traversal" {
|
||||||
|
s.shared(w, r)
|
||||||
|
} else {
|
||||||
|
s.Handler().ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
if w.Code != tc.want {
|
||||||
|
t.Fatalf("status=%d want=%d body=%q", w.Code, tc.want, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
r := httptest.NewRequest(http.MethodGet, "/chfs/shared/docs", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "你好 #?") {
|
||||||
|
t.Fatalf("shared directory listing status=%d body=%q", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "private") {
|
||||||
|
t.Fatalf("denied entry leaked in listing: %q", w.Body.String())
|
||||||
|
}
|
||||||
|
r = httptest.NewRequest(http.MethodHead, "/chfs/shared/docs/%E4%BD%A0%E5%A5%BD%20%23%3F.txt", nil)
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
s.Handler().ServeHTTP(w, r)
|
||||||
|
if w.Code != http.StatusOK || w.Body.Len() != 0 {
|
||||||
|
t.Fatalf("HEAD status=%d body=%d", w.Code, w.Body.Len())
|
||||||
|
}
|
||||||
|
if got := w.Header().Get("Content-Disposition"); !strings.Contains(got, "attachment") {
|
||||||
|
t.Fatalf("disposition=%q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) {
|
func TestDownloadDirArchiveHeadersAndEntries(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
dir := filepath.Join(root, "pack")
|
dir := filepath.Join(root, "pack")
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"container/list"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
type thumbnailCacheKey struct {
|
||||||
|
Path string
|
||||||
|
Size int64
|
||||||
|
ModTime int64
|
||||||
|
}
|
||||||
|
|
||||||
|
type thumbnailCacheEntry struct {
|
||||||
|
key thumbnailCacheKey
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
type thumbnailFlight struct {
|
||||||
|
done chan struct{}
|
||||||
|
data []byte
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
type thumbnailCache struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
items map[thumbnailCacheKey]*list.Element
|
||||||
|
order *list.List
|
||||||
|
flights map[thumbnailCacheKey]*thumbnailFlight
|
||||||
|
bytes int64
|
||||||
|
maxBytes int64
|
||||||
|
maxItems int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newThumbnailCache() *thumbnailCache {
|
||||||
|
return &thumbnailCache{
|
||||||
|
items: make(map[thumbnailCacheKey]*list.Element),
|
||||||
|
order: list.New(),
|
||||||
|
flights: make(map[thumbnailCacheKey]*thumbnailFlight),
|
||||||
|
maxBytes: 8 << 20,
|
||||||
|
maxItems: 64,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *thumbnailCache) Do(key thumbnailCacheKey, fn func() ([]byte, error)) ([]byte, error) {
|
||||||
|
c.mu.Lock()
|
||||||
|
if e, ok := c.items[key]; ok {
|
||||||
|
c.order.MoveToFront(e)
|
||||||
|
data := append([]byte(nil), e.Value.(thumbnailCacheEntry).data...)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
if f, ok := c.flights[key]; ok {
|
||||||
|
c.mu.Unlock()
|
||||||
|
<-f.done
|
||||||
|
return f.data, f.err
|
||||||
|
}
|
||||||
|
f := &thumbnailFlight{done: make(chan struct{})}
|
||||||
|
c.flights[key] = f
|
||||||
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
data, err := fn()
|
||||||
|
if err == nil && len(data) > 0 && int64(len(data)) <= c.maxBytes {
|
||||||
|
c.mu.Lock()
|
||||||
|
c.putLocked(key, data)
|
||||||
|
f.data = data
|
||||||
|
f.err = err
|
||||||
|
delete(c.flights, key)
|
||||||
|
close(f.done)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
f.err = err
|
||||||
|
delete(c.flights, key)
|
||||||
|
close(f.done)
|
||||||
|
c.mu.Unlock()
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *thumbnailCache) putLocked(key thumbnailCacheKey, data []byte) {
|
||||||
|
if e, ok := c.items[key]; ok {
|
||||||
|
old := e.Value.(thumbnailCacheEntry)
|
||||||
|
c.bytes -= int64(len(old.data))
|
||||||
|
e.Value = thumbnailCacheEntry{key: key, data: data}
|
||||||
|
c.bytes += int64(len(data))
|
||||||
|
c.order.MoveToFront(e)
|
||||||
|
} else {
|
||||||
|
e := c.order.PushFront(thumbnailCacheEntry{key: key, data: data})
|
||||||
|
c.items[key] = e
|
||||||
|
c.bytes += int64(len(data))
|
||||||
|
}
|
||||||
|
for c.bytes > c.maxBytes || c.order.Len() > c.maxItems {
|
||||||
|
e := c.order.Back()
|
||||||
|
if e == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
entry := e.Value.(thumbnailCacheEntry)
|
||||||
|
delete(c.items, entry.key)
|
||||||
|
c.bytes -= int64(len(entry.data))
|
||||||
|
c.order.Remove(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestThumbnailCacheDeduplicatesConcurrentBuilds(t *testing.T) {
|
||||||
|
c := newThumbnailCache()
|
||||||
|
key := thumbnailCacheKey{Path: "photo.jpg", Size: 10, ModTime: 20}
|
||||||
|
var builds atomic.Int32
|
||||||
|
const workers = 24
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make([][]byte, workers)
|
||||||
|
errs := make([]error, workers)
|
||||||
|
wg.Add(workers)
|
||||||
|
for i := 0; i < workers; i++ {
|
||||||
|
go func(i int) {
|
||||||
|
defer wg.Done()
|
||||||
|
results[i], errs[i] = c.Do(key, func() ([]byte, error) {
|
||||||
|
builds.Add(1)
|
||||||
|
return []byte("thumbnail"), nil
|
||||||
|
})
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
if builds.Load() != 1 {
|
||||||
|
t.Fatalf("thumbnail builder ran %d times, want 1", builds.Load())
|
||||||
|
}
|
||||||
|
for i := range results {
|
||||||
|
if errs[i] != nil || string(results[i]) != "thumbnail" {
|
||||||
|
t.Fatalf("worker %d got result %q, err=%v", i, results[i], errs[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbnailCacheDoesNotCacheErrors(t *testing.T) {
|
||||||
|
c := newThumbnailCache()
|
||||||
|
key := thumbnailCacheKey{Path: "broken.jpg"}
|
||||||
|
wantErr := errors.New("decode failed")
|
||||||
|
var builds atomic.Int32
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
_, err := c.Do(key, func() ([]byte, error) {
|
||||||
|
builds.Add(1)
|
||||||
|
return nil, wantErr
|
||||||
|
})
|
||||||
|
if !errors.Is(err, wantErr) {
|
||||||
|
t.Fatalf("got err %v, want %v", err, wantErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if builds.Load() != 2 {
|
||||||
|
t.Fatalf("error result was cached; builder ran %d times", builds.Load())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbnailCacheEvictsByBound(t *testing.T) {
|
||||||
|
c := newThumbnailCache()
|
||||||
|
c.maxItems = 2
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := thumbnailCacheKey{Path: string(rune('a' + i))}
|
||||||
|
if _, err := c.Do(key, func() ([]byte, error) { return []byte("x"), nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if len(c.items) != 2 || c.order.Len() != 2 {
|
||||||
|
t.Fatalf("cache size=%d/list=%d, want 2/2", len(c.items), c.order.Len())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestThumbnailCacheEvictsByBytes(t *testing.T) {
|
||||||
|
c := newThumbnailCache()
|
||||||
|
c.maxBytes = 3
|
||||||
|
for i := 0; i < 2; i++ {
|
||||||
|
key := thumbnailCacheKey{Path: string(rune('x' + i))}
|
||||||
|
if _, err := c.Do(key, func() ([]byte, error) { return []byte("xx"), nil }); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
if c.bytes > c.maxBytes || len(c.items) != 1 {
|
||||||
|
t.Fatalf("cache bytes=%d items=%d, want <=%d/1", c.bytes, len(c.items), c.maxBytes)
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
-56
File diff suppressed because one or more lines are too long
+37
-11
@@ -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 {
|
||||||
@@ -197,16 +215,24 @@ type jwtClaims struct {
|
|||||||
jwt.StandardClaims
|
jwt.StandardClaims
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewToken(username string, _ int) (string, error) {
|
func NewToken(username string, timeoutMinutes int, secrets ...string) (string, error) {
|
||||||
// CHFS v4 keeps the JWT itself valid for one year. session.timeout is
|
if timeoutMinutes <= 0 {
|
||||||
// retained as a compatibility configuration field, but it does not alter
|
timeoutMinutes = 30
|
||||||
// the JWT exp claim in the original implementation.
|
}
|
||||||
claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(365 * 24 * time.Hour).Unix()}}
|
secret := jwtSecret
|
||||||
|
if len(secrets) > 0 && secrets[0] != "" {
|
||||||
|
secret = secrets[0]
|
||||||
|
}
|
||||||
|
claims := jwtClaims{Acc: username, StandardClaims: jwt.StandardClaims{ExpiresAt: time.Now().Add(time.Duration(timeoutMinutes) * time.Minute).Unix()}}
|
||||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
return token.SignedString([]byte(jwtSecret))
|
return token.SignedString([]byte(secret))
|
||||||
}
|
}
|
||||||
|
|
||||||
func GetAccFromToken(raw string) (string, error) {
|
func GetAccFromToken(raw string, secrets ...string) (string, error) {
|
||||||
|
secret := jwtSecret
|
||||||
|
if len(secrets) > 0 && secrets[0] != "" {
|
||||||
|
secret = secrets[0]
|
||||||
|
}
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return "", fmt.Errorf("empty token")
|
return "", fmt.Errorf("empty token")
|
||||||
}
|
}
|
||||||
@@ -215,7 +241,7 @@ func GetAccFromToken(raw string) (string, error) {
|
|||||||
if t.Method != jwt.SigningMethodHS256 {
|
if t.Method != jwt.SigningMethodHS256 {
|
||||||
return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg())
|
return nil, fmt.Errorf("unexpected signing method: %s", t.Method.Alg())
|
||||||
}
|
}
|
||||||
return []byte(jwtSecret), nil
|
return []byte(secret), nil
|
||||||
})
|
})
|
||||||
if err != nil || token == nil || !token.Valid {
|
if err != nil || token == nil || !token.Valid {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|||||||
@@ -1,6 +1,33 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import "testing"
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
jwt "github.com/golang-jwt/jwt/v4"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTokenUsesConfiguredSecretAndTimeout(t *testing.T) {
|
||||||
|
const secret = "test-secret"
|
||||||
|
before := time.Now().Add(2 * time.Minute).Unix()
|
||||||
|
raw, err := NewToken("alice", 2, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var claims jwtClaims
|
||||||
|
token, err := jwt.ParseWithClaims(raw, &claims, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
return []byte(secret), nil
|
||||||
|
})
|
||||||
|
if err != nil || !token.Valid {
|
||||||
|
t.Fatalf("token should validate with configured secret: %v", err)
|
||||||
|
}
|
||||||
|
if claims.ExpiresAt < before-1 || claims.ExpiresAt > before+1 {
|
||||||
|
t.Fatalf("unexpected expiration: %v", claims.ExpiresAt)
|
||||||
|
}
|
||||||
|
if _, err := GetAccFromToken(raw, "wrong-secret"); err == nil {
|
||||||
|
t.Fatal("token validated with wrong secret")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMatchPath(t *testing.T) {
|
func TestMatchPath(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
-17
@@ -20,26 +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
|
||||||
HTMLTitle string
|
SessionTimeout int
|
||||||
HTMLNotice string
|
JWTSecret string
|
||||||
UserFile string
|
HTMLTitle string
|
||||||
UserHome string
|
HTMLNotice string
|
||||||
SSLCert string
|
UserFile string
|
||||||
SSLKey string
|
UserHome string
|
||||||
Accounts []AccountConfig
|
ThumbnailMaxSize int64
|
||||||
|
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) {
|
||||||
@@ -100,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)
|
||||||
@@ -122,6 +134,8 @@ func Load(path string) (Config, error) {
|
|||||||
if n, e := strconv.Atoi(val); e == nil && n > 0 {
|
if n, e := strconv.Atoi(val); e == nil && n > 0 {
|
||||||
c.SessionTimeout = n
|
c.SessionTimeout = n
|
||||||
}
|
}
|
||||||
|
case "jwt.secret":
|
||||||
|
c.JWTSecret = val
|
||||||
case "html.title":
|
case "html.title":
|
||||||
c.HTMLTitle = val
|
c.HTMLTitle = val
|
||||||
case "html.notice":
|
case "html.notice":
|
||||||
@@ -130,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":
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user