diff --git a/fastapi_app.py b/fastapi_app.py index 1aa17c9..7b7cc7d 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -82,6 +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 @asynccontextmanager @@ -190,6 +191,9 @@ app.include_router(memory.router) # 注册文件管理API路由 app.include_router(file_manager_router) +# 注册 WebDAV 路由 +app.include_router(webdav_router) + if __name__ == "__main__": # 启动 FastAPI 应用 diff --git a/routes/webdav.py b/routes/webdav.py new file mode 100644 index 0000000..60a2705 --- /dev/null +++ b/routes/webdav.py @@ -0,0 +1,365 @@ +""" +WebDAV 文件管理路由 +为 projects/robot/{bot_id} 目录提供 WebDAV 协议支持 +""" + +import os +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 +from fastapi.responses import FileResponse, Response as FastAPIResponse +import logging + +logger = logging.getLogger('app') + +router = APIRouter(prefix="/webdav", tags=["webdav"]) + +# WebDAV XML 命名空间 +DAV_NS = "DAV:" +DAV_PREFIX = "{DAV:}" + +ROBOT_BASE_DIR = Path("projects/robot") + + +def get_bot_root(bot_id: str) -> Path: + """获取 bot 的根目录,确保安全""" + # 防止路径遍历 + if ".." in bot_id or "/" in bot_id or "\\" in bot_id: + raise HTTPException(status_code=400, detail="无效的 bot_id") + root = ROBOT_BASE_DIR / bot_id + root.mkdir(parents=True, exist_ok=True) + return root + + +def resolve_safe_path(bot_root: Path, rel_path: str) -> Path: + """安全地解析相对路径,防止目录遍历""" + # 规范化,去掉开头的 / + rel_path = rel_path.strip("/") + if not rel_path: + return bot_root + target = (bot_root / rel_path).resolve() + # 确保在 bot_root 内 + try: + target.relative_to(bot_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 + 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) + + # displayname + dn = ET.SubElement(prop_el, f"{DAV_PREFIX}displayname") + dn.text = file_path.name + + # getlastmodified + lm = ET.SubElement(prop_el, f"{DAV_PREFIX}getlastmodified") + lm.text = format_http_date(mtime) + + if file_path.is_dir(): + # resourcetype = collection + rt = ET.SubElement(prop_el, f"{DAV_PREFIX}resourcetype") + ET.SubElement(rt, f"{DAV_PREFIX}collection") + else: + # resourcetype empty + ET.SubElement(prop_el, f"{DAV_PREFIX}resourcetype") + # getcontentlength + cl = ET.SubElement(prop_el, f"{DAV_PREFIX}getcontentlength") + cl.text = str(stat.st_size) + # getcontenttype + 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" + ) + + +# ===== PROPFIND ===== +@router.api_route("/{bot_id}/{path:path}", methods=["PROPFIND"]) +@router.api_route("/{bot_id}", methods=["PROPFIND"]) +async def propfind(bot_id: str, request: Request, path: str = ""): + """PROPFIND - 获取资源属性(列出目录)""" + bot_root = get_bot_root(bot_id) + target = resolve_safe_path(bot_root, path) + + if not target.exists(): + raise HTTPException(status_code=404, detail="资源不存在") + + depth = request.headers.get("Depth", "1") + base_href = f"/webdav/{bot_id}" + + responses = [] + + # 当前资源 + rel = path.strip("/") + 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)) + + # 如果是目录且 Depth != 0,列出子项 + 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 + + xml_body = build_multistatus(responses) + return Response( + content=xml_body, + status_code=207, + media_type="application/xml; charset=utf-8", + ) + + +# ===== OPTIONS ===== +@router.api_route("/{bot_id}/{path:path}", methods=["OPTIONS"]) +@router.api_route("/{bot_id}", methods=["OPTIONS"]) +async def options(bot_id: str, path: str = ""): + """OPTIONS - 返回支持的 WebDAV 方法""" + return Response( + status_code=200, + headers={ + "Allow": "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND", + "DAV": "1, 2", + }, + ) + + +# ===== GET ===== +@router.get("/{bot_id}/{path:path}") +async def get_file(bot_id: str, path: str): + """GET - 下载文件""" + bot_root = get_bot_root(bot_id) + target = resolve_safe_path(bot_root, path) + + if not target.exists(): + raise HTTPException(status_code=404, detail="资源不存在") + + if target.is_dir(): + # 对目录返回简单的 HTML 列表 + items = [] + for item in sorted(target.iterdir()): + if item.name.startswith("."): + continue + name = item.name + ("/" if item.is_dir() else "") + rel_href = f"/webdav/{bot_id}/{path.strip('/')}/{item.name}" if path.strip("/") else f"/webdav/{bot_id}/{item.name}" + items.append(f'