feat(server): 装配 WebDAV 与 admin/client API

- /<账号>/ chroot 隔离 + Basic 认证(401 带 WWW-Authenticate)+ 只读 403 + 路径逃逸 400
- admin API:登录/列表/新建/换口令/局部更新/删除(保留目录)/连接信息
- client API:登录/列表/上传/下载(Range)/新建文件夹/重命名/删除
- go:embed 内嵌前端,SPA 路由 / 与 /admin
- 修正 .gitignore 中误伤 cmd/davbox 的规则
This commit is contained in:
Yunling Zhang committed 2026-09-28 16:52:49 +08:00
1 parent 78673558c8
commit df5bff9b3c
8 files changed
+1368 -1

No files matched your search

-1
View File
@@ -1,6 +1,5 @@
# 产物
/davbox
davbox
*.exe
# 前端
+55
View File
@@ -0,0 +1,55 @@
// Command davbox 启动一个按账号隔离的 WebDAV 服务,并内置 admin / client 页面。
package main
import (
"flag"
"log"
"net/http"
"os"
"time"
"github.com/YLing2024/davbox/internal/account"
"github.com/YLing2024/davbox/internal/auth"
"github.com/YLing2024/davbox/internal/server"
)
func main() {
addr := flag.String("addr", "127.0.0.1:18900", "监听地址")
dataDir := flag.String("data", "./data", "数据目录")
flag.Parse()
if err := os.MkdirAll(*dataDir, 0o755); err != nil {
log.Fatalf("创建数据目录失败: %v", err)
}
store, err := account.Open(*dataDir)
if err != nil {
log.Fatalf("加载账号失败: %v", err)
}
secret, err := auth.LoadOrCreateSecret(*dataDir)
if err != nil {
log.Fatalf("加载会话密钥失败: %v", err)
}
adminHash, err := auth.LoadOrCreateAdmin(*dataDir)
if err != nil {
log.Fatalf("加载管理员口令失败: %v", err)
}
srv := server.New(server.Config{
DataDir: *dataDir,
Store: store,
AdminHash: adminHash,
Signer: auth.NewSigner(secret),
})
httpServer := &http.Server{
Addr: *addr,
Handler: srv.Handler(),
ReadHeaderTimeout: 15 * time.Second,
}
log.Printf("davbox 已启动,监听 http://%s,数据目录 %s", *addr, *dataDir)
if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("服务退出: %v", err)
}
}
+291
View File
@@ -0,0 +1,291 @@
package server
import (
"golang.org/x/crypto/bcrypt"
"errors"
"net/http"
"strings"
"time"
"github.com/YLing2024/davbox/internal/account"
"github.com/YLing2024/davbox/internal/auth"
"github.com/YLing2024/davbox/internal/usage"
)
const (
cookieAdmin = "davbox_admin"
cookieClient = "davbox_client"
sessionTTL = 12 * time.Hour
)
func setSessionCookie(w http.ResponseWriter, name, value string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: value,
Path: "/",
MaxAge: int(sessionTTL.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func clearSessionCookie(w http.ResponseWriter, name string) {
http.SetCookie(w, &http.Cookie{
Name: name,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Server) adminSession(r *http.Request) (auth.Session, bool) {
c, err := r.Cookie(cookieAdmin)
if err != nil {
return auth.Session{}, false
}
sess, ok := s.signer.Verify(c.Value)
if !ok || sess.Kind != "admin" {
return auth.Session{}, false
}
return sess, true
}
type accountView struct {
User string `json:"user"`
Root string `json:"root"`
Readonly bool `json:"readonly"`
Disabled bool `json:"disabled"`
Note string `json:"note"`
UsedBytes int64 `json:"usedBytes"`
UsedFiles int64 `json:"usedFiles"`
}
func (s *Server) viewOf(a account.Account) accountView {
b, f := s.usage.stat(a.User, a.Root, usage.Dir)
return accountView{
User: a.User, Root: a.Root, Readonly: a.Readonly, Disabled: a.Disabled,
Note: a.Note, UsedBytes: b, UsedFiles: f,
}
}
func requestBaseURL(r *http.Request) string {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
if xf := r.Header.Get("X-Forwarded-Proto"); xf != "" {
if i := strings.IndexByte(xf, ','); i >= 0 {
xf = xf[:i]
}
xf = strings.TrimSpace(xf)
if xf == "https" || xf == "http" {
scheme = xf
}
}
return scheme + "://" + r.Host
}
func connURL(r *http.Request, user string) string {
return requestBaseURL(r) + "/" + user
}
// handleAdmin 处理 /api/admin 下的全部请求。
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/admin")
rest = strings.TrimPrefix(rest, "/")
var parts []string
if rest != "" {
parts = strings.Split(rest, "/")
}
if len(parts) == 1 && parts[0] == "login" {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.adminLogin(w, r)
return
}
if _, ok := s.adminSession(r); !ok {
writeError(w, http.StatusUnauthorized, "未登录")
return
}
switch {
case len(parts) == 1 && parts[0] == "logout":
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
clearSessionCookie(w, cookieAdmin)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
case len(parts) == 1 && parts[0] == "accounts":
switch r.Method {
case http.MethodGet:
s.adminListAccounts(w, r)
case http.MethodPost:
s.adminCreateAccount(w, r)
default:
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
}
case len(parts) == 2 && parts[0] == "accounts":
user := parts[1]
switch r.Method {
case http.MethodPatch:
s.adminPatchAccount(w, r, user)
case http.MethodDelete:
s.adminDeleteAccount(w, r, user)
default:
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
}
case len(parts) == 3 && parts[0] == "accounts" && parts[2] == "conn":
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.adminConn(w, r, parts[1])
case len(parts) == 3 && parts[0] == "accounts" && parts[2] == "rotate":
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.adminRotate(w, r, parts[1])
default:
writeError(w, http.StatusNotFound, "未找到")
}
}
func (s *Server) adminLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
if bcrypt.CompareHashAndPassword(s.adminHash, []byte(body.Password)) != nil {
writeError(w, http.StatusUnauthorized, "口令错误")
return
}
setSessionCookie(w, cookieAdmin, s.signer.Issue("admin", "", sessionTTL))
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) adminListAccounts(w http.ResponseWriter, r *http.Request) {
accounts := s.store.List()
out := make([]accountView, 0, len(accounts))
for _, a := range accounts {
out = append(out, s.viewOf(a))
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) adminCreateAccount(w http.ResponseWriter, r *http.Request) {
var body struct {
User string `json:"user"`
Readonly bool `json:"readonly"`
Note string `json:"note"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
a, pass, err := s.store.Create(strings.TrimSpace(body.User), body.Readonly, body.Note)
switch {
case errors.Is(err, account.ErrInvalidName):
writeError(w, http.StatusBadRequest, "账号名只能用小写字母、数字、下划线或连字符,且不超过 32 位")
return
case errors.Is(err, account.ErrDuplicate):
writeError(w, http.StatusConflict, "账号已存在")
return
case err != nil:
writeError(w, http.StatusInternalServerError, "创建失败")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"user": a.User,
"pass": pass,
"url": connURL(r, a.User),
})
}
func (s *Server) adminConn(w http.ResponseWriter, r *http.Request, user string) {
a, ok := s.store.Get(user)
if !ok {
writeError(w, http.StatusNotFound, "账号不存在")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"url": connURL(r, a.User),
"user": a.User,
"pass": a.Pass,
})
}
func (s *Server) adminRotate(w http.ResponseWriter, r *http.Request, user string) {
pass, err := s.store.Rotate(user)
if errors.Is(err, account.ErrNotFound) {
writeError(w, http.StatusNotFound, "账号不存在")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "换口令失败")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"user": user,
"pass": pass,
"url": connURL(r, user),
})
}
func (s *Server) adminPatchAccount(w http.ResponseWriter, r *http.Request, user string) {
var body struct {
Readonly *bool `json:"readonly"`
Disabled *bool `json:"disabled"`
Note *string `json:"note"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
a, err := s.store.Update(user, account.Patch{
Readonly: body.Readonly,
Disabled: body.Disabled,
Note: body.Note,
})
if errors.Is(err, account.ErrNotFound) {
writeError(w, http.StatusNotFound, "账号不存在")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "更新失败")
return
}
writeJSON(w, http.StatusOK, s.viewOf(a))
}
func (s *Server) adminDeleteAccount(w http.ResponseWriter, r *http.Request, user string) {
err := s.store.Delete(user)
if errors.Is(err, account.ErrNotFound) {
writeError(w, http.StatusNotFound, "账号不存在")
return
}
if err != nil {
writeError(w, http.StatusInternalServerError, "删除失败")
return
}
s.forgetLocks(user)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"message": "账号已删除,磁盘目录保留",
})
}
+385
View File
@@ -0,0 +1,385 @@
package server
import (
"errors"
"io"
"mime"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"syscall"
"github.com/YLing2024/davbox/internal/account"
)
var errBadPath = errors.New("路径不合法")
func (s *Server) clientSession(r *http.Request) (string, bool) {
c, err := r.Cookie(cookieClient)
if err != nil {
return "", false
}
sess, ok := s.signer.Verify(c.Value)
if !ok || sess.Kind != "client" || sess.User == "" {
return "", false
}
return sess.User, true
}
func (s *Server) requireClient(w http.ResponseWriter, r *http.Request) (account.Account, bool) {
user, ok := s.clientSession(r)
if !ok {
writeError(w, http.StatusUnauthorized, "未登录")
return account.Account{}, false
}
a, ok := s.store.Get(user)
if !ok || a.Disabled {
clearSessionCookie(w, cookieClient)
writeError(w, http.StatusUnauthorized, "账号不可用")
return account.Account{}, false
}
return a, true
}
// clientPath 把客户端路径解析到账号 root 内;任何逃逸都返回 errBadPath。
func (s *Server) clientPath(acct account.Account, p string) (string, error) {
if p == "" {
p = "/"
}
if !strings.HasPrefix(p, "/") {
return "", errBadPath
}
if strings.Contains(p, "..") || strings.Contains(p, `\`) || strings.ContainsRune(p, 0) {
return "", errBadPath
}
clean := path.Clean(p)
full := filepath.Join(acct.Root, filepath.FromSlash(clean))
rel, err := filepath.Rel(acct.Root, full)
if err != nil {
return "", errBadPath
}
if rel != "." && (rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator))) {
return "", errBadPath
}
return full, nil
}
// handleClient 处理 /api/client 下的全部请求。
func (s *Server) handleClient(w http.ResponseWriter, r *http.Request) {
rest := strings.TrimPrefix(r.URL.Path, "/api/client")
rest = strings.TrimPrefix(rest, "/")
var parts []string
if rest != "" {
parts = strings.Split(rest, "/")
}
if len(parts) == 1 && parts[0] == "login" {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.clientLogin(w, r)
return
}
acct, ok := s.requireClient(w, r)
if !ok {
return
}
if r.URL.Path == "/api/client/logout" {
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
clearSessionCookie(w, cookieClient)
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
return
}
switch {
case r.URL.Path == "/api/client/list":
if r.Method != http.MethodGet {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.clientList(w, r, acct)
case r.URL.Path == "/api/client/raw" || strings.HasPrefix(r.URL.Path, "/api/client/raw/"):
p := strings.TrimPrefix(r.URL.Path, "/api/client/raw")
switch r.Method {
case http.MethodGet, http.MethodHead:
s.clientDownload(w, r, acct, p)
case http.MethodPut:
s.clientUpload(w, r, acct, p)
default:
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
}
case r.URL.Path == "/api/client/mkdir":
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.clientMkdir(w, r, acct)
case r.URL.Path == "/api/client/rename":
if r.Method != http.MethodPost {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.clientRename(w, r, acct)
case r.URL.Path == "/api/client/entry":
if r.Method != http.MethodDelete {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
s.clientDelete(w, r, acct)
default:
writeError(w, http.StatusNotFound, "未找到")
}
}
func (s *Server) clientLogin(w http.ResponseWriter, r *http.Request) {
var body struct {
User string `json:"user"`
Pass string `json:"pass"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
a, ok := s.store.Authenticate(strings.TrimSpace(body.User), body.Pass)
if !ok {
writeError(w, http.StatusUnauthorized, "账号或口令错误")
return
}
setSessionCookie(w, cookieClient, s.signer.Issue("client", a.User, sessionTTL))
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "user": a.User})
}
type entryView struct {
Name string `json:"name"`
IsDir bool `json:"isDir"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
}
func (s *Server) clientList(w http.ResponseWriter, r *http.Request, acct account.Account) {
full, err := s.clientPath(acct, r.URL.Query().Get("path"))
if err != nil {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
entries, err := os.ReadDir(full)
if err != nil {
writeError(w, http.StatusNotFound, "目录不存在")
return
}
out := make([]entryView, 0, len(entries))
for _, e := range entries {
info, err := e.Info()
if err != nil {
continue
}
v := entryView{Name: e.Name(), IsDir: e.IsDir(), Mtime: info.ModTime().UnixMilli()}
if !e.IsDir() {
v.Size = info.Size()
}
out = append(out, v)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].IsDir != out[j].IsDir {
return out[i].IsDir
}
li, lj := strings.ToLower(out[i].Name), strings.ToLower(out[j].Name)
if li != lj {
return li < lj
}
return out[i].Name < out[j].Name
})
writeJSON(w, http.StatusOK, out)
}
func (s *Server) clientDownload(w http.ResponseWriter, r *http.Request, acct account.Account, p string) {
full, err := s.clientPath(acct, p)
if err != nil {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
f, err := os.Open(full)
if err != nil {
writeError(w, http.StatusNotFound, "文件不存在")
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.IsDir() {
writeError(w, http.StatusBadRequest, "不能下载目录")
return
}
ct := mime.TypeByExtension(filepath.Ext(info.Name()))
if ct == "" {
ct = "application/octet-stream"
}
w.Header().Set("Content-Type", ct)
w.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(info.Name()))
http.ServeContent(w, r, info.Name(), info.ModTime(), f)
}
func (s *Server) clientUpload(w http.ResponseWriter, r *http.Request, acct account.Account, p string) {
if acct.Readonly {
writeError(w, http.StatusForbidden, "只读账号")
return
}
full, err := s.clientPath(acct, p)
if err != nil {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
parent := filepath.Dir(full)
if info, statErr := os.Stat(parent); statErr != nil || !info.IsDir() {
writeError(w, http.StatusBadRequest, "父目录不存在")
return
}
tmp, err := os.CreateTemp(parent, ".davbox-upload-*")
if err != nil {
writeError(w, http.StatusInternalServerError, "写入失败")
return
}
tmpName := tmp.Name()
ok := false
defer func() {
if !ok {
os.Remove(tmpName)
}
}()
if _, err := io.Copy(tmp, r.Body); err != nil {
tmp.Close()
writeError(w, http.StatusInternalServerError, "写入失败")
return
}
if err := tmp.Chmod(0o644); err != nil {
tmp.Close()
writeError(w, http.StatusInternalServerError, "写入失败")
return
}
if err := tmp.Close(); err != nil {
writeError(w, http.StatusInternalServerError, "写入失败")
return
}
if err := os.Rename(tmpName, full); err != nil {
writeError(w, http.StatusInternalServerError, "写入失败")
return
}
ok = true
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
func (s *Server) clientMkdir(w http.ResponseWriter, r *http.Request, acct account.Account) {
if acct.Readonly {
writeError(w, http.StatusForbidden, "只读账号")
return
}
var body struct {
Path string `json:"path"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
full, err := s.clientPath(acct, body.Path)
if err != nil {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
if err := os.Mkdir(full, 0o755); err != nil {
switch {
case os.IsExist(err):
writeError(w, http.StatusConflict, "已存在")
case os.IsNotExist(err):
writeError(w, http.StatusBadRequest, "父目录不存在")
default:
writeError(w, http.StatusBadRequest, "创建失败")
}
return
}
writeJSON(w, http.StatusCreated, map[string]bool{"ok": true})
}
func (s *Server) clientRename(w http.ResponseWriter, r *http.Request, acct account.Account) {
if acct.Readonly {
writeError(w, http.StatusForbidden, "只读账号")
return
}
var body struct {
From string `json:"from"`
To string `json:"to"`
}
if err := decodeJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, "请求格式错误")
return
}
from, err := s.clientPath(acct, body.From)
if err != nil || body.From == "/" {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
to, err := s.clientPath(acct, body.To)
if err != nil || body.To == "/" {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
if _, err := os.Stat(from); err != nil {
writeError(w, http.StatusNotFound, "来源不存在")
return
}
if info, statErr := os.Stat(filepath.Dir(to)); statErr != nil || !info.IsDir() {
writeError(w, http.StatusBadRequest, "目标父目录不存在")
return
}
if _, err := os.Stat(to); err == nil {
writeError(w, http.StatusConflict, "目标已存在")
return
}
if err := os.Rename(from, to); err != nil {
writeError(w, http.StatusInternalServerError, "重命名失败")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
func (s *Server) clientDelete(w http.ResponseWriter, r *http.Request, acct account.Account) {
if acct.Readonly {
writeError(w, http.StatusForbidden, "只读账号")
return
}
p := r.URL.Query().Get("path")
if p == "" || p == "/" {
writeError(w, http.StatusBadRequest, "不能删除根目录")
return
}
full, err := s.clientPath(acct, p)
if err != nil {
writeError(w, http.StatusBadRequest, "路径不合法")
return
}
if err := os.Remove(full); err != nil {
switch {
case os.IsNotExist(err):
writeError(w, http.StatusNotFound, "条目不存在")
case errors.Is(err, syscall.ENOTEMPTY), errors.Is(err, syscall.EEXIST):
writeError(w, http.StatusConflict, "目录非空")
default:
writeError(w, http.StatusBadRequest, "删除失败")
}
return
}
writeJSON(w, http.StatusOK, map[string]bool{"ok": true})
}
+150
View File
@@ -0,0 +1,150 @@
// Package server 负责路由装配:WebDAV、admin API、client API 与静态资源。
package server
import (
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"time"
"golang.org/x/net/webdav"
"github.com/YLing2024/davbox/internal/account"
"github.com/YLing2024/davbox/internal/auth"
)
// Config 是装配服务器所需的依赖。
type Config struct {
DataDir string
Store *account.Store
AdminHash []byte
Signer *auth.Signer
}
// Server 持有全部运行期状态。
type Server struct {
dataDir string
store *account.Store
adminHash []byte
signer *auth.Signer
davMu sync.Mutex
locks map[string]webdav.LockSystem
usage *usageCache
}
// New 构造服务器。
func New(cfg Config) *Server {
return &Server{
dataDir: cfg.DataDir,
store: cfg.Store,
adminHash: cfg.AdminHash,
signer: cfg.Signer,
locks: map[string]webdav.LockSystem{},
usage: newUsageCache(30 * time.Second),
}
}
// Handler 返回顶层 http.Handler。
func (s *Server) Handler() http.Handler {
return http.HandlerFunc(s.route)
}
func (s *Server) route(w http.ResponseWriter, r *http.Request) {
decoded := r.URL.Path
if unsafePath(r.URL.EscapedPath(), decoded) {
writeError(w, http.StatusBadRequest, "请求路径不合法")
return
}
switch {
case decoded == "/api/admin" || strings.HasPrefix(decoded, "/api/admin/"):
s.handleAdmin(w, r)
case decoded == "/api/client" || strings.HasPrefix(decoded, "/api/client/"):
s.handleClient(w, r)
case decoded == "/" || decoded == "/admin" || decoded == "/admin/":
s.serveIndex(w, r)
case strings.HasPrefix(decoded, "/assets/"):
s.serveStatic(w, r)
default:
if seg := firstSegment(decoded); seg != "" {
if _, ok := s.store.Get(seg); ok {
s.handleDAV(w, r, seg)
return
}
}
writeError(w, http.StatusNotFound, "未找到")
}
}
func firstSegment(p string) string {
p = strings.TrimPrefix(p, "/")
if i := strings.IndexByte(p, '/'); i >= 0 {
return p[:i]
}
return p
}
// unsafePath 拒绝含 `..`、编码后的 `%2e`、反斜杠、空字节的路径。
func unsafePath(escaped, decoded string) bool {
low := strings.ToLower(escaped)
for _, bad := range []string{"%2e", "%5c", "%00"} {
if strings.Contains(low, bad) {
return true
}
}
if strings.Contains(decoded, "..") || strings.Contains(decoded, `\`) || strings.ContainsRune(decoded, 0) {
return true
}
return false
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}
func writeError(w http.ResponseWriter, status int, msg string) {
writeJSON(w, status, map[string]string{"error": msg})
}
func decodeJSON(r *http.Request, v any) error {
dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
return dec.Decode(v)
}
type usageEntry struct {
bytes int64
files int64
exp time.Time
}
type usageCache struct {
mu sync.Mutex
ttl time.Duration
m map[string]usageEntry
}
func newUsageCache(ttl time.Duration) *usageCache {
return &usageCache{ttl: ttl, m: map[string]usageEntry{}}
}
func (c *usageCache) stat(key, root string, compute func(string) (int64, int64, error)) (int64, int64) {
c.mu.Lock()
if e, ok := c.m[key]; ok && time.Now().Before(e.exp) {
c.mu.Unlock()
return e.bytes, e.files
}
c.mu.Unlock()
bytes, files, _ := compute(root)
c.mu.Lock()
c.m[key] = usageEntry{bytes: bytes, files: files, exp: time.Now().Add(c.ttl)}
c.mu.Unlock()
return bytes, files
}
+329
View File
@@ -0,0 +1,329 @@
package server
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"golang.org/x/crypto/bcrypt"
"github.com/YLing2024/davbox/internal/account"
"github.com/YLing2024/davbox/internal/auth"
)
const testAdminPass = "test-admin-pass"
func newTestServer(t *testing.T) (*Server, *account.Store, string) {
t.Helper()
dir := t.TempDir()
store, err := account.Open(dir)
if err != nil {
t.Fatal(err)
}
hash, err := bcrypt.GenerateFromPassword([]byte(testAdminPass), bcrypt.MinCost)
if err != nil {
t.Fatal(err)
}
signer := auth.NewSigner([]byte("0123456789abcdef0123456789abcdef"))
return New(Config{DataDir: dir, Store: store, AdminHash: hash, Signer: signer}), store, dir
}
func request(s *Server, method, target, user, pass string, body io.Reader, headers map[string]string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, body)
if user != "" {
req.SetBasicAuth(user, pass)
}
for k, v := range headers {
req.Header.Set(k, v)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestDAVUnauthenticatedChallenge(t *testing.T) {
s, store, _ := newTestServer(t)
_, _, _ = store.Create("app1", false, "")
rec := request(s, "PROPFIND", "/app1/", "", "", nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("未认证应为 401,实际 %d", rec.Code)
}
if got := rec.Header().Get("WWW-Authenticate"); got != `Basic realm="davbox"` {
t.Fatalf("缺少或错误的 WWW-Authenticate: %q", got)
}
}
func TestDAVWrongPassword(t *testing.T) {
s, store, _ := newTestServer(t)
_, _, _ = store.Create("app1", false, "")
rec := request(s, "PROPFIND", "/app1/", "app1", "nope", nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("错口令应为 401,实际 %d", rec.Code)
}
if rec.Header().Get("WWW-Authenticate") == "" {
t.Fatal("错口令响应也应带 WWW-Authenticate")
}
}
func TestDAVDisabledAccountRejected(t *testing.T) {
s, store, _ := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
disabled := true
if _, err := store.Update("app1", account.Patch{Disabled: &disabled}); err != nil {
t.Fatal(err)
}
rec := request(s, "PROPFIND", "/app1/", "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized {
t.Fatalf("停用账号应为 401,实际 %d", rec.Code)
}
}
func TestDAVCrossAccount(t *testing.T) {
s, store, _ := newTestServer(t)
_, passA, _ := store.Create("app1", false, "")
_, _, _ = store.Create("app2", false, "")
rec := request(s, "PROPFIND", "/app2/", "app1", passA, nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusUnauthorized && rec.Code != http.StatusForbidden && rec.Code != http.StatusNotFound {
t.Fatalf("跨账号应为 401/403/404,实际 %d", rec.Code)
}
}
func TestDAVPathEscapeRejected(t *testing.T) {
s, store, _ := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
cases := []string{
"/app1/%2e%2e/secret",
"/app1/..%2fsecret",
"/app1/%2E%2E/secret",
}
for _, target := range cases {
rec := request(s, "PROPFIND", target, "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != http.StatusBadRequest {
t.Fatalf("路径 %s 应为 400,实际 %d", target, rec.Code)
}
}
}
func TestDAVSixVerbs(t *testing.T) {
s, store, dir := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
rec := request(s, "MKCOL", "/app1/notes", "app1", pass, nil, nil)
if rec.Code != http.StatusCreated {
t.Fatalf("MKCOL 应为 201,实际 %d", rec.Code)
}
rec = request(s, http.MethodPut, "/app1/notes/a.txt", "app1", pass, strings.NewReader("hello-davbox"), nil)
if rec.Code != http.StatusCreated {
t.Fatalf("PUT 应为 201,实际 %d", rec.Code)
}
rec = request(s, http.MethodGet, "/app1/notes/a.txt", "app1", pass, nil, nil)
if rec.Code != http.StatusOK || rec.Body.String() != "hello-davbox" {
t.Fatalf("GET 应为 200 且内容一致,实际 %d %q", rec.Code, rec.Body.String())
}
rec = request(s, "PROPFIND", "/app1/", "app1", pass, nil, map[string]string{"Depth": "0"})
if rec.Code != 207 {
t.Fatalf("PROPFIND Depth:0 应为 207,实际 %d", rec.Code)
}
rec = request(s, "PROPFIND", "/app1/notes/", "app1", pass, nil, map[string]string{"Depth": "1"})
if rec.Code != 207 {
t.Fatalf("PROPFIND Depth:1 应为 207,实际 %d", rec.Code)
}
rec = request(s, "MOVE", "/app1/notes/a.txt", "app1", pass, nil, map[string]string{
"Destination": "http://example.com/app1/notes/b.txt",
})
if rec.Code != http.StatusCreated && rec.Code != http.StatusNoContent {
t.Fatalf("MOVE 应为 201/204,实际 %d", rec.Code)
}
// 内容确实只落在 app1 的 root 下。
if _, err := os.Stat(filepath.Join(dir, "data", "app1", "notes", "b.txt")); err != nil {
t.Fatalf("MOVE 结果应落在账号 root: %v", err)
}
rec = request(s, http.MethodDelete, "/app1/notes/b.txt", "app1", pass, nil, nil)
if rec.Code != http.StatusNoContent {
t.Fatalf("DELETE 应为 204,实际 %d", rec.Code)
}
}
func TestDAVReadonlyAccount(t *testing.T) {
s, store, dir := newTestServer(t)
_, pass, _ := store.Create("ro", true, "")
if err := os.WriteFile(filepath.Join(dir, "data", "ro", "readme.txt"), []byte("ok"), 0o644); err != nil {
t.Fatal(err)
}
rec := request(s, http.MethodPut, "/ro/x.txt", "ro", pass, strings.NewReader("nope"), nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读账号 PUT 应为 403,实际 %d", rec.Code)
}
rec = request(s, "MKCOL", "/ro/dir", "ro", pass, nil, nil)
if rec.Code != http.StatusForbidden {
t.Fatalf("只读账号 MKCOL 应为 403,实际 %d", rec.Code)
}
rec = request(s, http.MethodGet, "/ro/readme.txt", "ro", pass, nil, nil)
if rec.Code != http.StatusOK || rec.Body.String() != "ok" {
t.Fatalf("只读账号 GET 应为 200,实际 %d %q", rec.Code, rec.Body.String())
}
if _, err := os.Stat(filepath.Join(dir, "data", "ro", "x.txt")); !os.IsNotExist(err) {
t.Fatal("只读账号不应产生文件")
}
}
func TestAdminAPIExplicitFlow(t *testing.T) {
s, _, dir := newTestServer(t)
// 错口令
rec := request(s, http.MethodPost, "/api/admin/login", "", "", strings.NewReader(`{"password":"bad"}`), nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("错口令应为 401,实际 %d", rec.Code)
}
// 正确口令,取 cookie
rec = request(s, http.MethodPost, "/api/admin/login", "", "", strings.NewReader(`{"password":"`+testAdminPass+`"}`), nil)
if rec.Code != http.StatusOK {
t.Fatalf("正确口令应为 200,实际 %d", rec.Code)
}
cookies := rec.Result().Cookies()
if len(cookies) == 0 {
t.Fatal("登录后应下发 cookie")
}
// 带 cookie 访问列表
rec = doWithCookie(s, http.MethodGet, "/api/admin/accounts", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("列表应为 200,实际 %d", rec.Code)
}
// 无 cookie 访问列表
rec = request(s, http.MethodGet, "/api/admin/accounts", "", "", nil, nil)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("未登录列表应为 401,实际 %d", rec.Code)
}
// 建账号
rec = doWithCookie(s, http.MethodPost, "/api/admin/accounts", cookies, strings.NewReader(`{"user":"newapp","readonly":false}`))
if rec.Code != http.StatusCreated {
t.Fatalf("建账号应为 201,实际 %d", rec.Code)
}
var created struct {
User string `json:"user"`
Pass string `json:"pass"`
URL string `json:"url"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &created); err != nil {
t.Fatal(err)
}
if created.User != "newapp" || created.Pass == "" || created.URL == "" {
t.Fatalf("建账号响应不完整: %+v", created)
}
// 换口令后旧口令失效
var newPass string
rec = doWithCookie(s, http.MethodPost, "/api/admin/accounts/newapp/rotate", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("换口令应为 200,实际 %d", rec.Code)
}
{
var rot struct {
Pass string `json:"pass"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &rot); err != nil {
t.Fatal(err)
}
newPass = rot.Pass
}
if _, ok := s.store.Authenticate("newapp", created.Pass); ok {
t.Fatal("旧口令应失效")
}
if _, ok := s.store.Authenticate("newapp", newPass); !ok {
t.Fatal("新口令应可用")
}
// 删除后列表消失、目录仍在
rec = doWithCookie(s, http.MethodDelete, "/api/admin/accounts/newapp", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("删除应为 200,实际 %d", rec.Code)
}
if _, ok := s.store.Get("newapp"); ok {
t.Fatal("删除后列表应消失")
}
if _, err := os.Stat(filepath.Join(dir, "data", "newapp")); err != nil {
t.Fatalf("删除后目录应保留: %v", err)
}
}
func TestClientAPIExplicitFlow(t *testing.T) {
s, store, _ := newTestServer(t)
_, pass, _ := store.Create("app1", false, "")
// 登录
rec := request(s, http.MethodPost, "/api/client/login", "", "", strings.NewReader(`{"user":"app1","pass":"`+pass+`"}`), nil)
if rec.Code != http.StatusOK {
t.Fatalf("client 登录应为 200,实际 %d", rec.Code)
}
cookies := rec.Result().Cookies()
// 上传
rec = doWithCookie(s, http.MethodPut, "/api/client/raw/hello.txt", cookies, strings.NewReader("client-body"))
if rec.Code != http.StatusCreated {
t.Fatalf("上传应为 201,实际 %d", rec.Code)
}
// 列表
rec = doWithCookie(s, http.MethodGet, "/api/client/list?path=/", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("列表应为 200,实际 %d", rec.Code)
}
if !strings.Contains(rec.Body.String(), "hello.txt") {
t.Fatalf("列表应包含上传的文件: %s", rec.Body.String())
}
// 下载内容一致
rec = doWithCookie(s, http.MethodGet, "/api/client/raw/hello.txt", cookies, nil)
if rec.Code != http.StatusOK || rec.Body.String() != "client-body" {
t.Fatalf("下载应为 200 且内容一致,实际 %d %q", rec.Code, rec.Body.String())
}
// 重命名
rec = doWithCookie(s, http.MethodPost, "/api/client/rename", cookies, strings.NewReader(`{"from":"/hello.txt","to":"/renamed.txt"}`))
if rec.Code != http.StatusOK {
t.Fatalf("重命名应为 200,实际 %d", rec.Code)
}
// 删除
rec = doWithCookie(s, http.MethodDelete, "/api/client/entry?path=/renamed.txt", cookies, nil)
if rec.Code != http.StatusOK {
t.Fatalf("删除应为 200,实际 %d", rec.Code)
}
// 路径逃逸
rec = doWithCookie(s, http.MethodGet, "/api/client/list?path=/../", cookies, nil)
if rec.Code != http.StatusBadRequest {
t.Fatalf("逃逸路径应为 400,实际 %d", rec.Code)
}
}
func doWithCookie(s *Server, method, target string, cookies []*http.Cookie, body io.Reader) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, target, body)
for _, c := range cookies {
req.AddCookie(c)
}
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
+75
View File
@@ -0,0 +1,75 @@
package server
import (
"io"
"io/fs"
"mime"
"net/http"
"path"
"strings"
"github.com/YLing2024/davbox/web"
)
func distFS() (fs.FS, error) {
return fs.Sub(web.Dist, "dist")
}
// serveIndex 返回 SPA 入口,/ 与 /admin 共用同一份 index.html。
func (s *Server) serveIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
sub, err := distFS()
if err != nil {
writeError(w, http.StatusInternalServerError, "前端资源不可用")
return
}
data, err := fs.ReadFile(sub, "index.html")
if err != nil {
writeError(w, http.StatusInternalServerError, "前端资源不可用")
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
_, _ = w.Write(data)
}
// serveStatic 返回 /assets/ 下的构建产物。
func (s *Server) serveStatic(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeError(w, http.StatusMethodNotAllowed, "方法不允许")
return
}
sub, err := distFS()
if err != nil {
writeError(w, http.StatusInternalServerError, "前端资源不可用")
return
}
name := strings.TrimPrefix(path.Clean(r.URL.Path), "/")
if name == "" || strings.Contains(name, "..") {
writeError(w, http.StatusNotFound, "未找到")
return
}
f, err := sub.Open(name)
if err != nil {
writeError(w, http.StatusNotFound, "未找到")
return
}
defer f.Close()
info, err := f.Stat()
if err != nil || info.IsDir() {
writeError(w, http.StatusNotFound, "未找到")
return
}
if ct := mime.TypeByExtension(path.Ext(name)); ct != "" {
w.Header().Set("Content-Type", ct)
}
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
if rs, ok := f.(io.ReadSeeker); ok {
http.ServeContent(w, r, info.Name(), info.ModTime(), rs)
return
}
_, _ = io.Copy(w, f)
}
+83
View File
@@ -0,0 +1,83 @@
package server
import (
"log"
"net/http"
"golang.org/x/net/webdav"
"github.com/YLing2024/davbox/internal/account"
)
// isWriteMethod 列出只读账号需要拒绝的动词。
func isWriteMethod(method string) bool {
switch method {
case http.MethodPut, http.MethodDelete, "MKCOL", "MOVE", "COPY", "PROPPATCH":
return true
}
return false
}
func writeAuthChallenge(w http.ResponseWriter) {
w.Header().Set("WWW-Authenticate", `Basic realm="davbox"`)
writeError(w, http.StatusUnauthorized, "未认证")
}
// lockSystem 为每个账号复用同一个内存锁系统。
func (s *Server) lockSystem(user string) webdav.LockSystem {
s.davMu.Lock()
defer s.davMu.Unlock()
ls := s.locks[user]
if ls == nil {
ls = webdav.NewMemLS()
s.locks[user] = ls
}
return ls
}
// forgetLocks 在账号被删除后释放其锁系统。
func (s *Server) forgetLocks(user string) {
s.davMu.Lock()
defer s.davMu.Unlock()
delete(s.locks, user)
}
// handleDAV 完成认证、只读检查后交给官方 webdav.Handler。
func (s *Server) handleDAV(w http.ResponseWriter, r *http.Request, user string) {
authUser, pass, ok := r.BasicAuth()
if !ok || authUser != user {
writeAuthChallenge(w)
return
}
acct, ok := s.store.Authenticate(user, pass)
if !ok {
writeAuthChallenge(w)
return
}
if acct.Readonly && isWriteMethod(r.Method) {
writeError(w, http.StatusForbidden, "只读账号")
return
}
h := &webdav.Handler{
Prefix: "/" + user + "/",
FileSystem: webdav.Dir(acct.Root),
LockSystem: s.lockSystem(user),
Logger: func(req *http.Request, err error) {
if err != nil {
// 只记录方法、路径与错误,绝不触碰 Authorization 头。
log.Printf("webdav %s %s: %v", req.Method, req.URL.Path, err)
}
},
}
h.ServeHTTP(w, r)
}
// davHandlerFor 供测试直接拿到某账号的处理器。
func (s *Server) davHandlerFor(acct account.Account) http.Handler {
return &webdav.Handler{
Prefix: "/" + acct.User + "/",
FileSystem: webdav.Dir(acct.Root),
LockSystem: s.lockSystem(acct.User),
}
}