diff --git a/fastapi_app.py b/fastapi_app.py index 7b7cc7d..20977f6 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -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) +# 挂载 WsgiDAV(WSGI 应用通过 WSGIMiddleware 集成到 ASGI) +from starlette.middleware.wsgi import WSGIMiddleware +app.mount("/webdav", WSGIMiddleware(wsgidav_app)) if __name__ == "__main__": diff --git a/poetry.lock b/poetry.lock index 8c85c90..91a8dd0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 6e561ea..a14f34b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/requirements.txt b/requirements.txt index 8fde40f..a7a07e5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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" diff --git a/routes/webdav.py b/routes/webdav.py index 9590f2b..a1a655e 100644 --- a/routes/webdav.py +++ b/routes/webdav.py @@ -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 '\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'
  • {name}
  • ') - display_path = path.strip("/") or resource_type - html = f"

    Index of /{display_path}/

    " - 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)