diff --git a/.gitignore b/.gitignore index bb3ee2c..f81d286 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # 产物 /davbox -davbox *.exe # 前端 diff --git a/cmd/davbox/main.go b/cmd/davbox/main.go new file mode 100644 index 0000000..e730993 --- /dev/null +++ b/cmd/davbox/main.go @@ -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) + } +} diff --git a/internal/server/admin.go b/internal/server/admin.go new file mode 100644 index 0000000..df25bd2 --- /dev/null +++ b/internal/server/admin.go @@ -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": "账号已删除,磁盘目录保留", + }) +} diff --git a/internal/server/client.go b/internal/server/client.go new file mode 100644 index 0000000..0aaa001 --- /dev/null +++ b/internal/server/client.go @@ -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}) +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..a73978b --- /dev/null +++ b/internal/server/server.go @@ -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 +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go new file mode 100644 index 0000000..a97b226 --- /dev/null +++ b/internal/server/server_test.go @@ -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 +} diff --git a/internal/server/static.go b/internal/server/static.go new file mode 100644 index 0000000..e2776fe --- /dev/null +++ b/internal/server/static.go @@ -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) +} diff --git a/internal/server/webdav.go b/internal/server/webdav.go new file mode 100644 index 0000000..20bb1f5 --- /dev/null +++ b/internal/server/webdav.go @@ -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), + } +}