Compare commits

...
10 Commits
11 changed files with 407 additions and 29 deletions
+7
View File
@@ -0,0 +1,7 @@
.git
.gitignore
bin
data
*.log
*.tmp
.DS_Store
+18
View File
@@ -0,0 +1,18 @@
FROM golang:1.24-alpine AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
ARG VERSION=dev
ARG TARGETOS=linux
ARG TARGETARCH=amd64
ARG TARGETVARIANT=
RUN set -eux; GOARM=""; if [ "$TARGETARCH" = "arm" ] && [ "$TARGETVARIANT" = "v7" ]; then GOARM=7; fi; CGO_ENABLED=0 GOOS="$TARGETOS" GOARCH="$TARGETARCH" GOARM="$GOARM" go build -trimpath -ldflags "-s -w -X main.version=${VERSION}" -o /out/chfs-ng ./cmd/chfs
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
COPY --from=build /out/chfs-ng /usr/local/bin/chfs-ng
VOLUME ["/data"]
EXPOSE 80
ENTRYPOINT ["/usr/local/bin/chfs-ng"]
CMD ["-path", "/data", "-port", "80"]
+46
View File
@@ -0,0 +1,46 @@
APP := chfs-ng
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS := -s -w -X main.version=$(VERSION)
OUT := bin
.PHONY: build test race vet check run build-linux-amd64 build-linux-arm64 build-linux-armv7 build-linux cross docker-build docker-run clean
build:
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o $(OUT)/$(APP) ./cmd/chfs
test:
go test ./...
race:
go test -race ./...
vet:
go vet ./...
check: test race vet
git diff --check
run:
go run -ldflags '$(LDFLAGS)' ./cmd/chfs
build-linux-amd64:
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '$(LDFLAGS)' -o $(OUT)/$(APP)-linux-amd64 ./cmd/chfs
build-linux-arm64:
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags '$(LDFLAGS)' -o $(OUT)/$(APP)-linux-arm64 ./cmd/chfs
build-linux-armv7:
CGO_ENABLED=0 GOOS=linux GOARCH=arm GOARM=7 go build -trimpath -ldflags '$(LDFLAGS)' -o $(OUT)/$(APP)-linux-armv7 ./cmd/chfs
build-linux: build-linux-amd64 build-linux-arm64 build-linux-armv7
cross: build-linux
docker-build:
docker build --build-arg VERSION=$(VERSION) -t $(APP):$(VERSION) -t $(APP):latest .
docker-run:
docker run --rm -p 8080:80 -v $(PWD)/data:/data $(APP):latest -path /data -port 80
clean:
rm -rf $(OUT)
+149
View File
@@ -0,0 +1,149 @@
# CHFS-NG
CHFS-NG 是基于 CHFS 思路重构的轻量级、自托管 Web 文件管理器。它保留单二进制、低资源占用和 WebDAV 的特点,同时提供现代化文件管理 UI、动态用户、个人私有盘以及移动端友好操作。
## 特性
- 单 Go 二进制,前端资源内嵌,无外部前端依赖
- 现代文件管理 UI:列表/网格、排序、多选、拖拽、批量复制/移动/删除、搜索
- 图片 Gallery、音视频预览、视频倍速/PiP、音频迷你播放器
- 在线文本/代码编辑,支持 Ctrl/Cmd+S 保存
- 上传队列、进度、取消与重试,支持文件夹拖拽上传
- 动态用户管理与独立个人私有目录
- 多根目录与目录级读写/删除权限
- 分享、二维码移动传输、WebDAV
- 健康检查:/healthz
- 构建产物使用 -s -w,适合轻量 NAS
## 快速开始
### 直接运行
```bash
make build
./bin/chfs-ng -path /srv/files -port 8080
```
访问 http://127.0.0.1:8080/。
### Docker Compose
在仓库根目录执行:
```bash
mkdir -p deploy/data
cd deploy
docker compose up -d --build
```
默认宿主机端口为 8080,数据位于 deploy/data。可用 CHFS_PORT=9090 docker compose up -d --build 修改端口。
## 启动参数
| 参数 | 默认值 | 说明 |
| --- | --- | --- |
| -path | /data | 共享根目录;可用 | 分隔多个根目录 |
| -port | 80 | HTTP 监听端口 |
| -file | 空 | 配置文件路径 |
| -allow | 空 | 允许访问的 IPv4 地址 |
| -rule | 空 | 访问规则,可重复指定 |
| -version | false | 输出版本后退出 |
示例:
```bash
./bin/chfs-ng -path /srv/files -port 8080 -file /etc/chfs-ng/config.ini
```
显式指定的命令行参数会覆盖配置文件中的对应值。
## 动态用户与个人私有盘
CHFS-NG 支持运行时管理动态用户。管理员可以在 Web UI 的“用户管理”中创建、启用/禁用、修改密码和删除用户,并为用户设置个人空间。
默认个人空间根目录为:
```
users/
├── alice/
├── bob/
└── ...
```
用户登录后,“我的空间”指向自己的个人目录。个人目录由系统自动创建,并默认具有该用户的读、写、删除权限;其他普通用户不会因为登录而自动获得这些目录的访问权。
个人空间根目录可通过配置项 UserHome 指定。动态用户数据默认持久化到操作系统用户配置目录下的 chfs-ng/users.json;Docker 部署时,建议把用户文件配置到持久化的 /data 下。
管理员账号可以通过配置文件提供,例如:
```ini
[account]
username = admin
password = change-me
role = admin
```
实际配置字段以当前版本配置解析器支持的格式为准。
## WebDAV
WebDAV 地址:
```
http://HOST:PORT/webdav
```
### Windows
“此电脑” → “映射网络驱动器”,输入 http://HOST:PORT/webdav,使用 CHFS-NG 用户名和密码登录。生产环境建议通过 HTTPS 暴露。
### macOS
Finder → “前往” → “连接服务器”,输入:
```
http(s)://HOST:PORT/webdav
```
### Linux
桌面文件管理器选择“连接到服务器”,GNOME Files 可使用:
```
dav://HOST:PORT/webdav
davs://HOST:PORT/webdav
```
命令行可使用支持 WebDAV 的客户端,例如 cadaver。
WebDAV 与 Web UI 使用同一套用户权限模型。多根目录模式下保持根目录隔离,不允许跨虚拟根目录复制或移动。
## 交叉编译
```bash
make build-linux-amd64
make build-linux-arm64
make build-linux-armv7
make build-linux
```
输出:
```
bin/chfs-ng-linux-amd64
bin/chfs-ng-linux-arm64
bin/chfs-ng-linux-armv7
```
所有目标均使用 CGO_ENABLED=0、-trimpath 和 -ldflags "-s -w",不依赖目标机器上的 Go 环境。
## 开发与验证
```bash
make test
make race
make vet
make check
```
版本号默认来自 git describe --tags --always --dirty,也可以:
```bash
make build VERSION=v1.0.0
./bin/chfs-ng -version
```
## Docker
采用 Go Alpine 多阶段构建:builder 只负责编译,runtime 仅保留 CHFS-NG、CA 证书和时区数据,并提供容器健康检查。
```bash
docker build --build-arg VERSION=v1.0.0 -t chfs-ng:v1.0.0 .
docker run -d --name chfs-ng --restart unless-stopped -p 8080:80 -v /srv/chfs:/data chfs-ng:v1.0.0
```
最终镜像目标控制在 15 MB 以内;实际大小会随 Go/Alpine 版本变化。
## 数据与安全
- /data 为推荐持久化目录
- 生产环境建议使用 HTTPS 反向代理
- 不要使用弱管理员密码
- 动态用户数据库需要与数据目录一起备份
- 公开部署时建议配合防火墙/反向代理
- WebDAV 优先使用 HTTPS
## License
本项目许可证以仓库中的 LICENSE 文件为准。
+8
View File
@@ -16,7 +16,10 @@ import (
"chfs-ng/internal/storage" "chfs-ng/internal/storage"
) )
var version = "dev"
func main() { func main() {
showVersion := flag.Bool("version", false, "print version and exit")
file := flag.String("file", "", "configuration file") file := flag.String("file", "", "configuration file")
pathFlag := flag.String("path", "/data", "shared path") pathFlag := flag.String("path", "/data", "shared path")
port := flag.Int("port", 80, "listen port") port := flag.Int("port", 80, "listen port")
@@ -24,6 +27,11 @@ func main() {
var rules multiFlag var rules multiFlag
flag.Var(&rules, "rule", "access rule (repeatable)") flag.Var(&rules, "rule", "access rule (repeatable)")
flag.Parse() flag.Parse()
if *showVersion {
println(version)
return
}
api.Version = version
cfg := config.Default() cfg := config.Default()
if *file != "" { if *file != "" {
+13
View File
@@ -0,0 +1,13 @@
services:
chfs:
build:
context: ..
args:
VERSION: ${VERSION:-dev}
image: chfs-ng:${VERSION:-latest}
restart: unless-stopped
ports:
- "${CHFS_PORT:-8080}:80"
volumes:
- ./data:/data
command: ["-path", "/data", "-port", "80"]
+24
View File
@@ -0,0 +1,24 @@
package api
import (
"encoding/json"
"net/http"
)
type healthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
}
var Version = "dev"
func (s *Server) health(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_ = json.NewEncoder(w).Encode(healthResponse{Status: "ok", Version: Version})
}
@@ -6,8 +6,10 @@ import (
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"chfs-ng/internal/auth" "chfs-ng/internal/auth"
@@ -149,3 +151,44 @@ func TestHTTPPermissionMatrix(t *testing.T) {
}) })
} }
} }
func TestHTTPBatchDeleteAndTransferConflict(t *testing.T) {
s, root := permissionTestServer(t, auth.Read|auth.Write|auth.Delete)
h := s.Handler()
if err := os.WriteFile(filepath.Join(root, "second.txt"), []byte("second"), 0o644); err != nil {
t.Fatal(err)
}
var b bytes.Buffer
mw := multipart.NewWriter(&b)
_ = mw.WriteField("filepath", "file.txt")
_ = mw.WriteField("filepath", "second.txt")
_ = mw.Close()
rr := doRequest(h, http.MethodDelete, "/chfs/rmfiles", &b, mw.FormDataContentType())
if rr.Code != http.StatusNoContent {
t.Fatalf("batch delete: got %d", rr.Code)
}
if _, err := os.Stat(filepath.Join(root, "file.txt")); !os.IsNotExist(err) {
t.Fatalf("file.txt still exists: %v", err)
}
if _, err := os.Stat(filepath.Join(root, "second.txt")); !os.IsNotExist(err) {
t.Fatalf("second.txt still exists: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "source.txt"), []byte("source"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "target.txt"), []byte("target"), 0o644); err != nil {
t.Fatal(err)
}
form := url.Values{"source": {"source.txt"}, "destination": {"target.txt"}}
copyReq := doRequest(h, http.MethodPost, "/chfs/copy", strings.NewReader(form.Encode()), "application/x-www-form-urlencoded")
if copyReq.Code != http.StatusBadRequest {
t.Fatalf("copy conflict: got %d", copyReq.Code)
}
data, err := os.ReadFile(filepath.Join(root, "target.txt"))
if err != nil {
t.Fatal(err)
}
if string(data) != "target" {
t.Fatalf("target overwritten: %q", data)
}
}
+20
View File
@@ -33,3 +33,23 @@ func TestRootEscapesPlainNotice(t *testing.T) {
t.Fatal("plain notice was not escaped") t.Fatal("plain notice was not escaped")
} }
} }
func TestHealthz(t *testing.T) {
s := &Server{}
old := Version
Version = "test"
defer func() { Version = old }()
rr := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if got := rr.Header().Get("Cache-Control"); got != "no-store" {
t.Fatalf("cache-control=%q", got)
}
if !strings.Contains(rr.Body.String(), `"status":"ok"`) || !strings.Contains(rr.Body.String(), `"version":"test"`) {
t.Fatalf("unexpected body=%s", rr.Body.String())
}
}
+1
View File
@@ -68,6 +68,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("/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)
File diff suppressed because one or more lines are too long