routes/webdav.py — 完全重写,从手写 WebDAV 协议改为使用 WsgiDAV 开源库

This commit is contained in:
朱潮 2026-03-09 18:33:05 +08:00
parent 4ad1c96bf3
commit 5d97be9557
5 changed files with 93 additions and 373 deletions

View File

@ -82,7 +82,7 @@ logger = logging.getLogger('app')
# Import route modules
from routes import chat, files, projects, system, skill_manager, database, memory
from routes.webdav import router as webdav_router
from routes.webdav import wsgidav_app
@asynccontextmanager
@ -191,8 +191,9 @@ app.include_router(memory.router)
# 注册文件管理API路由
app.include_router(file_manager_router)
# 注册 WebDAV 路由
app.include_router(webdav_router)
# 挂载 WsgiDAVWSGI 应用通过 WSGIMiddleware 集成到 ASGI
from starlette.middleware.wsgi import WSGIMiddleware
app.mount("/webdav", WSGIMiddleware(wsgidav_app))
if __name__ == "__main__":

47
poetry.lock generated
View File

@ -926,6 +926,18 @@ perplexity = ["langchain-perplexity (>=1.0.0,<2.0.0)"]
vertexai = ["langchain-google-vertexai (>=3.0.0,<4.0.0)"]
xai = ["langchain-xai (>=1.0.0,<2.0.0)"]
[[package]]
name = "defusedxml"
version = "0.7.1"
description = "XML bomb protection for Python stdlib modules"
optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*"
groups = ["main"]
files = [
{file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"},
{file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"},
]
[[package]]
name = "deprecated"
version = "1.3.1"
@ -1884,6 +1896,18 @@ files = [
{file = "json_repair-0.29.10.tar.gz", hash = "sha256:8050f9db6e6a42f843e21b3fe8410308b0f6085bfd81506343552522b6b707f8"},
]
[[package]]
name = "json5"
version = "0.13.0"
description = "A Python implementation of the JSON5 data format."
optional = false
python-versions = ">=3.8.0"
groups = ["main"]
files = [
{file = "json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc"},
{file = "json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf"},
]
[[package]]
name = "jsonpatch"
version = "1.33"
@ -6147,6 +6171,27 @@ files = [
[package.extras]
dev = ["pytest", "setuptools"]
[[package]]
name = "wsgidav"
version = "4.3.3"
description = "Generic and extendable WebDAV server based on WSGI"
optional = false
python-versions = "*"
groups = ["main"]
files = [
{file = "WsgiDAV-4.3.3-py3-none-any.whl", hash = "sha256:8d96b0f05ad7f280572e99d1c605962a853d715f8e934298555d0c47ef275e88"},
{file = "wsgidav-4.3.3.tar.gz", hash = "sha256:5f0ad71bea72def3018b6ba52da3bcb83f61e0873c27225344582805d6e52b9e"},
]
[package.dependencies]
defusedxml = "*"
Jinja2 = "*"
json5 = "*"
PyYAML = "*"
[package.extras]
pam = ["python-pam"]
[[package]]
name = "xlrd"
version = "2.0.2"
@ -6574,4 +6619,4 @@ cffi = ["cffi (>=1.17,<2.0) ; platform_python_implementation != \"PyPy\" and pyt
[metadata]
lock-version = "2.1"
python-versions = ">=3.12,<4.0"
content-hash = "a8aef80608df449d575c613456bd339bbb6e407133b1f7c68893b0ec70f04eab"
content-hash = "dbe40b78bc1b7796331da5e14512ae6992f783cadca977bc66b098642d1e8cd9"

View File

@ -36,6 +36,7 @@ dependencies = [
"psycopg2-binary (>=2.9.11,<3.0.0)",
"json-repair (>=0.29.0,<0.30.0)",
"tiktoken (>=0.5.0,<1.0.0)",
"wsgidav (>=4.3.3,<5.0.0)",
]
[tool.poetry.requires-plugins]

View File

@ -27,6 +27,7 @@ daytona-toolbox-api-client==0.127.0 ; python_version >= "3.12" and python_versio
daytona==0.127.0 ; python_version >= "3.12" and python_version < "4.0"
deepagents-cli==0.0.25 ; python_version >= "3.12" and python_version < "4.0"
deepagents==0.4.3 ; python_version >= "3.12" and python_version < "4.0"
defusedxml==0.7.1 ; python_version >= "3.12" and python_version < "4.0"
deprecated==1.3.1 ; python_version >= "3.12" and python_version < "4.0"
distro==1.9.0 ; python_version >= "3.12" and python_version < "4.0"
docstring-parser==0.17.0 ; python_version >= "3.12" and python_version < "4.0"
@ -58,6 +59,7 @@ jinja2==3.1.6 ; python_version >= "3.12" and python_version < "4.0"
jiter==0.11.1 ; python_version >= "3.12" and python_version < "4.0"
joblib==1.5.2 ; python_version >= "3.12" and python_version < "4.0"
json-repair==0.29.10 ; python_version >= "3.12" and python_version < "4.0"
json5==0.13.0 ; python_version >= "3.12" and python_version < "4.0"
jsonpatch==1.33 ; python_version >= "3.12" and python_version < "4.0"
jsonpointer==3.0.0 ; python_version >= "3.12" and python_version < "4.0"
jsonschema-specifications==2025.9.1 ; python_version >= "3.12" and python_version < "4.0"
@ -189,6 +191,7 @@ wcmatch==10.1 ; python_version >= "3.12" and python_version < "4.0"
wcwidth==0.2.14 ; python_version >= "3.12" and python_version < "4.0"
websockets==15.0.1 ; python_version >= "3.12" and python_version < "4.0"
wrapt==2.0.1 ; python_version >= "3.12" and python_version < "4.0"
wsgidav==4.3.3 ; python_version >= "3.12" and python_version < "4.0"
xlrd==2.0.2 ; python_version >= "3.12" and python_version < "4.0"
xxhash==3.6.0 ; python_version >= "3.12" and python_version < "4.0"
yarl==1.22.0 ; python_version >= "3.12" and python_version < "4.0"

View File

@ -1,50 +1,17 @@
"""
WebDAV 文件管理路由
使用 WsgiDAV 开源库提供完整的 WebDAV 协议支持
projects/{resource_type}/ 目录提供 WebDAV 协议支持
支持的资源类型: robot, dataset
支持的资源类型: robot, docs
"""
import secrets
import shutil
import mimetypes
from pathlib import Path
from datetime import datetime, timezone
from xml.etree import ElementTree as ET
from fastapi import APIRouter, Request, HTTPException, Response, Depends
from fastapi.responses import FileResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import logging
from wsgidav.wsgidav_app import WsgiDAVApp
from wsgidav.fs_dav_provider import FilesystemProvider
from utils.settings import WEBDAV_USERNAME, WEBDAV_PASSWORD
logger = logging.getLogger('app')
security = HTTPBasic(realm="WebDAV")
def verify_credentials(credentials: HTTPBasicCredentials = Depends(security)):
"""验证 WebDAV Basic Auth 账号密码"""
username_correct = secrets.compare_digest(credentials.username, WEBDAV_USERNAME)
password_correct = secrets.compare_digest(credentials.password, WEBDAV_PASSWORD)
if not (username_correct and password_correct):
raise HTTPException(
status_code=401,
detail="认证失败",
headers={"WWW-Authenticate": 'Basic realm="WebDAV"'},
)
return credentials
# 不在 router 级别加认证OPTIONS 需要免认证macOS Finder 等客户端预检不带凭据)
router = APIRouter(prefix="/webdav", tags=["webdav"])
# 需要认证的依赖,用于非 OPTIONS 的端点
auth_dep = [Depends(verify_credentials)]
# WebDAV XML 命名空间
DAV_NS = "DAV:"
DAV_PREFIX = "{DAV:}"
PROJECTS_BASE_DIR = Path("projects")
ALLOWED_RESOURCE_TYPES = {"robot", "docs"}
@ -52,338 +19,41 @@ ALLOWED_RESOURCE_TYPES = {"robot", "docs"}
for resource_type in ALLOWED_RESOURCE_TYPES:
(PROJECTS_BASE_DIR / resource_type).mkdir(parents=True, exist_ok=True)
# 构建 provider_mapping每个资源类型映射到对应目录
provider_mapping = {}
for resource_type in ALLOWED_RESOURCE_TYPES:
abs_path = str((PROJECTS_BASE_DIR / resource_type).resolve())
provider_mapping[f"/{resource_type}"] = FilesystemProvider(abs_path)
def get_resource_type_root(resource_type: str) -> Path:
"""获取资源类型的根目录"""
if resource_type not in ALLOWED_RESOURCE_TYPES:
raise HTTPException(
status_code=400,
detail=f"无效的资源类型,仅支持: {', '.join(ALLOWED_RESOURCE_TYPES)}"
)
return PROJECTS_BASE_DIR / resource_type
def resolve_safe_path(root: Path, rel_path: str) -> Path:
"""安全地解析相对路径,防止目录遍历"""
rel_path = rel_path.strip("/")
if not rel_path:
return root
target = (root / rel_path).resolve()
try:
target.relative_to(root.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="访问被拒绝")
return target
def format_http_date(dt: datetime) -> str:
return dt.strftime("%a, %d %b %Y %H:%M:%S GMT")
def get_file_props(file_path: Path, href: str) -> ET.Element:
"""生成单个资源的 DAV:response XML 元素"""
response_el = ET.SubElement(ET.Element("dummy"), f"{DAV_PREFIX}response")
href_el = ET.SubElement(response_el, f"{DAV_PREFIX}href")
href_el.text = href
propstat_el = ET.SubElement(response_el, f"{DAV_PREFIX}propstat")
prop_el = ET.SubElement(propstat_el, f"{DAV_PREFIX}prop")
status_el = ET.SubElement(propstat_el, f"{DAV_PREFIX}status")
status_el.text = "HTTP/1.1 200 OK"
stat = file_path.stat()
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
dn = ET.SubElement(prop_el, f"{DAV_PREFIX}displayname")
dn.text = file_path.name
lm = ET.SubElement(prop_el, f"{DAV_PREFIX}getlastmodified")
lm.text = format_http_date(mtime)
if file_path.is_dir():
rt = ET.SubElement(prop_el, f"{DAV_PREFIX}resourcetype")
ET.SubElement(rt, f"{DAV_PREFIX}collection")
else:
ET.SubElement(prop_el, f"{DAV_PREFIX}resourcetype")
cl = ET.SubElement(prop_el, f"{DAV_PREFIX}getcontentlength")
cl.text = str(stat.st_size)
ct = ET.SubElement(prop_el, f"{DAV_PREFIX}getcontenttype")
mime, _ = mimetypes.guess_type(str(file_path))
ct.text = mime or "application/octet-stream"
return response_el
def build_multistatus(responses: list[ET.Element]) -> str:
"""构建 DAV:multistatus XML"""
ET.register_namespace("D", DAV_NS)
multistatus = ET.Element(f"{DAV_PREFIX}multistatus")
for resp in responses:
multistatus.append(resp)
return '<?xml version="1.0" encoding="utf-8"?>\n' + ET.tostring(
multistatus, encoding="unicode"
)
def _collect_propfind_responses(root: Path, target: Path, base_href: str, rel: str, depth: str) -> list[ET.Element]:
"""收集 PROPFIND 响应"""
responses = []
href = f"{base_href}/{rel}" if rel else f"{base_href}/"
if not href.endswith("/") and target.is_dir():
href += "/"
responses.append(get_file_props(target, href))
if target.is_dir() and depth != "0":
try:
for item in sorted(target.iterdir()):
if item.name.startswith("."):
continue
item_rel = f"{rel}/{item.name}" if rel else item.name
item_href = f"{base_href}/{item_rel}"
if item.is_dir():
item_href += "/"
responses.append(get_file_props(item, item_href))
except PermissionError:
pass
return responses
# ===== PROPFIND =====
@router.api_route("/{resource_type}/{path:path}", methods=["PROPFIND"], dependencies=auth_dep)
@router.api_route("/{resource_type}", methods=["PROPFIND"], dependencies=auth_dep)
async def propfind(resource_type: str, request: Request, path: str = ""):
"""PROPFIND - 获取资源属性(列出目录)"""
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
depth = request.headers.get("Depth", "1")
base_href = f"/webdav/{resource_type}"
rel = path.strip("/")
responses = _collect_propfind_responses(root, target, base_href, rel, depth)
xml_body = build_multistatus(responses)
return Response(
content=xml_body,
status_code=207,
media_type="application/xml; charset=utf-8",
)
# ===== OPTIONS =====
@router.api_route("/{resource_type}/{path:path}", methods=["OPTIONS"])
@router.api_route("/{resource_type}", methods=["OPTIONS"])
async def options(resource_type: str, path: str = ""):
"""OPTIONS - 返回支持的 WebDAV 方法"""
return Response(
status_code=200,
headers={
"Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND",
"DAV": "1, 2",
config = {
"mount_path": "/webdav",
"provider_mapping": provider_mapping,
"http_authenticator": {
"domain_controller": "wsgidav.dc.simple_dc.SimpleDomainController",
"accept_basic": True,
"accept_digest": False,
"default_to_digest": False,
},
"simple_dc": {
"user_mapping": {
"*": {
WEBDAV_USERNAME: {
"password": WEBDAV_PASSWORD,
},
},
},
)
},
"verbose": 1,
"lock_storage": True,
"property_manager": True,
"dir_browser": {
"enable": True,
"icon": True,
"response_trailer": "",
},
"hotfixes": {
"re_encode_path_info": True,
},
}
# ===== GET =====
@router.get("/{resource_type}/{path:path}", dependencies=auth_dep)
@router.get("/{resource_type}", dependencies=auth_dep)
async def get_file(resource_type: str, path: str = ""):
"""GET - 下载文件或浏览目录"""
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
if target.is_dir():
items = []
for item in sorted(target.iterdir()):
if item.name.startswith("."):
continue
name = item.name + ("/" if item.is_dir() else "")
rel = path.strip("/")
item_href = f"/webdav/{resource_type}/{rel}/{item.name}" if rel else f"/webdav/{resource_type}/{item.name}"
items.append(f'<li><a href="{item_href}">{name}</a></li>')
display_path = path.strip("/") or resource_type
html = f"<html><body><h1>Index of /{display_path}/</h1><ul>{''.join(items)}</ul></body></html>"
return Response(content=html, media_type="text/html")
mime, _ = mimetypes.guess_type(str(target))
return FileResponse(path=str(target), media_type=mime or "application/octet-stream")
# ===== HEAD =====
@router.head("/{resource_type}/{path:path}", dependencies=auth_dep)
@router.head("/{resource_type}", dependencies=auth_dep)
async def head_file(resource_type: str, path: str = ""):
"""HEAD - 获取文件元数据"""
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
headers = {}
if target.is_file():
stat = target.stat()
mime, _ = mimetypes.guess_type(str(target))
headers["Content-Type"] = mime or "application/octet-stream"
headers["Content-Length"] = str(stat.st_size)
mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc)
headers["Last-Modified"] = format_http_date(mtime)
return Response(status_code=200, headers=headers)
# ===== PUT =====
@router.put("/{resource_type}/{path:path}", dependencies=auth_dep)
async def put_file(resource_type: str, path: str, request: Request):
"""PUT - 上传/覆盖文件"""
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
target.parent.mkdir(parents=True, exist_ok=True)
is_new = not target.exists()
body = await request.body()
with open(target, "wb") as f:
f.write(body)
logger.info(f"WebDAV PUT: {resource_type}/{path} ({len(body)} bytes)")
return Response(status_code=201 if is_new else 204)
# ===== DELETE =====
@router.delete("/{resource_type}/{path:path}", dependencies=auth_dep)
async def delete_resource(resource_type: str, path: str):
"""DELETE - 删除文件或目录"""
if not path.strip("/"):
raise HTTPException(status_code=403, detail="不能删除资源根目录")
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
if target.is_file():
target.unlink()
else:
shutil.rmtree(target)
logger.info(f"WebDAV DELETE: {resource_type}/{path}")
return Response(status_code=204)
# ===== MKCOL =====
@router.api_route("/{resource_type}/{path:path}", methods=["MKCOL"], dependencies=auth_dep)
async def mkcol(resource_type: str, path: str):
"""MKCOL - 创建目录"""
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if target.exists():
raise HTTPException(status_code=405, detail="资源已存在")
if not target.parent.exists():
raise HTTPException(status_code=409, detail="父目录不存在")
target.mkdir()
logger.info(f"WebDAV MKCOL: {resource_type}/{path}")
return Response(status_code=201)
# ===== COPY =====
@router.api_route("/{resource_type}/{path:path}", methods=["COPY"], dependencies=auth_dep)
async def copy_resource(resource_type: str, path: str, request: Request):
"""COPY - 复制文件或目录"""
root = get_resource_type_root(resource_type)
source = resolve_safe_path(root, path)
if not source.exists():
raise HTTPException(status_code=404, detail="源资源不存在")
destination = request.headers.get("Destination")
if not destination:
raise HTTPException(status_code=400, detail="缺少 Destination 头")
dest_path = _parse_destination(destination, resource_type)
dest_target = resolve_safe_path(root, dest_path)
overwrite = request.headers.get("Overwrite", "T").upper() == "T"
existed = dest_target.exists()
if existed and not overwrite:
raise HTTPException(status_code=412, detail="目标已存在且不允许覆盖")
if existed:
if dest_target.is_file():
dest_target.unlink()
else:
shutil.rmtree(dest_target)
dest_target.parent.mkdir(parents=True, exist_ok=True)
if source.is_file():
shutil.copy2(str(source), str(dest_target))
else:
shutil.copytree(str(source), str(dest_target))
logger.info(f"WebDAV COPY: {resource_type}/{path} -> {dest_path}")
return Response(status_code=204 if existed else 201)
# ===== MOVE =====
@router.api_route("/{resource_type}/{path:path}", methods=["MOVE"], dependencies=auth_dep)
async def move_resource(resource_type: str, path: str, request: Request):
"""MOVE - 移动/重命名文件或目录"""
root = get_resource_type_root(resource_type)
source = resolve_safe_path(root, path)
if not source.exists():
raise HTTPException(status_code=404, detail="源资源不存在")
destination = request.headers.get("Destination")
if not destination:
raise HTTPException(status_code=400, detail="缺少 Destination 头")
dest_path = _parse_destination(destination, resource_type)
dest_target = resolve_safe_path(root, dest_path)
overwrite = request.headers.get("Overwrite", "T").upper() == "T"
existed = dest_target.exists()
if existed and not overwrite:
raise HTTPException(status_code=412, detail="目标已存在且不允许覆盖")
if existed:
if dest_target.is_file():
dest_target.unlink()
else:
shutil.rmtree(dest_target)
dest_target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(dest_target))
logger.info(f"WebDAV MOVE: {resource_type}/{path} -> {dest_path}")
return Response(status_code=204 if existed else 201)
def _parse_destination(destination: str, resource_type: str) -> str:
"""从 Destination 头中提取相对路径"""
from urllib.parse import urlparse, unquote
parsed = urlparse(destination)
path = unquote(parsed.path)
prefix = f"/webdav/{resource_type}/"
if path.startswith(prefix):
return path[len(prefix):]
prefix_no_slash = f"/webdav/{resource_type}"
if path == prefix_no_slash:
return ""
raise HTTPException(status_code=400, detail="无效的 Destination 路径")
wsgidav_app = WsgiDAVApp(config)