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:
1 parent
78673558c8
commit
df5bff9b3c
8 files changed
+1368
-1
No files matched your search
@@ -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": "账号已删除,磁盘目录保留",
|
||||
})
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user