From 25dec10b67b5a251124ec7d3114befd4d98fe6e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9C=B1=E6=BD=AE?= Date: Fri, 7 Nov 2025 14:06:54 +0800 Subject: [PATCH] add webdav --- WEBDAV_GUIDE.md | 97 ++++++++++++++++++++++++++ fastapi_app.py | 161 +++---------------------------------------- poetry.lock | 35 +++++++++- pyproject.toml | 1 + requirements.txt | 2 + webdav_server.py | 174 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 317 insertions(+), 153 deletions(-) create mode 100644 WEBDAV_GUIDE.md create mode 100644 webdav_server.py diff --git a/WEBDAV_GUIDE.md b/WEBDAV_GUIDE.md new file mode 100644 index 0000000..b4d00fa --- /dev/null +++ b/WEBDAV_GUIDE.md @@ -0,0 +1,97 @@ +# WebDAV 文件管理服务 + +您的项目现在已经成功集成了 WebDAV 文件管理服务,替代了原来功能单一的 `/api/v1/projects/tree` 和 `/api/v1/projects/subtree` 接口。 + +## 功能特性 + +- ✅ 完整的文件/文件夹操作(增删改查、移动、重命名) +- ✅ 支持大文件上传/下载 +- ✅ 支持文件夹批量操作 +- ✅ 可挂载为网络驱动器 +- ✅ 支持任何WebDAV客户端 + +## 访问方式 + +### 1. 浏览器访问 +直接在浏览器中打开: +``` +http://localhost:8001/webdav +``` + +### 2. 系统挂载 + +#### Windows +1. 打开 "此电脑" +2. 右键点击 "网络位置" → "添加一个网络位置" +3. 输入:`http://localhost:8001/webdav` +4. 完成,即可在网络驱动器中访问 + +#### macOS +1. 打开 "访达" +2. 前往 → 连接服务器(Cmd+K) +3. 输入:`http://localhost:8001/webdav` +4. 连接 + +#### Linux +使用 davfs2 挂载: +```bash +sudo mount -t davfs http://localhost:8001/webdav /mnt/webdav +``` + +### 3. 移动端App +使用任何WebDAV客户端App: +- iOS: Documents by Readdle, WebDAV Navigator +- Android: Solid Explorer, WebDAV Client + +## API 操作示例 + +### 查看目录列表 +```bash +curl -X PROPFIND http://localhost:8001/webdav/ +``` + +### 上传文件 +```bash +curl -X PUT http://localhost:8001/webdav/test.txt -d "Hello World" +``` + +### 下载文件 +```bash +curl http://localhost:8001/webdav/test.txt +``` + +### 创建文件夹 +```bash +curl -X MKCOL http://localhost:8001/webdav/new_folder +``` + +### 删除文件 +```bash +curl -X DELETE http://localhost:8001/webdav/test.txt +``` + +### 移动文件 +```bash +curl -X MOVE http://localhost:8001/webdav/test.txt -H "Destination: /webdav/backup/test.txt" +``` + +## 安全说明 + +当前配置为开发环境,无需认证即可访问。在生产环境中,建议: + +1. 启用HTTPS +2. 配置用户认证 +3. 限制访问权限 + +## 支持的文件操作 + +| 操作 | HTTP方法 | 说明 | +|------|----------|------| +| 读取文件/目录 | PROPFIND | 获取文件信息和目录列表 | +| 创建文件 | PUT | 上传或更新文件 | +| 创建目录 | MKCOL | 创建新文件夹 | +| 删除文件/目录 | DELETE | 删除文件或空目录 | +| 移动/重命名 | MOVE | 移动或重命名文件/目录 | +| 复制 | COPY | 复制文件/目录 | + +现在您可以使用任何支持WebDAV的工具来管理您的 `projects` 文件夹,享受完整的文件管理功能! \ No newline at end of file diff --git a/fastapi_app.py b/fastapi_app.py index b1479dd..e188917 100644 --- a/fastapi_app.py +++ b/fastapi_app.py @@ -14,6 +14,8 @@ from fastapi import FastAPI, HTTPException, Depends, Header, UploadFile, File from fastapi.responses import StreamingResponse, HTMLResponse, FileResponse from fastapi.staticfiles import StaticFiles from fastapi.middleware.cors import CORSMiddleware +from fastapi.middleware.wsgi import WSGIMiddleware +from webdav_server import create_webdav_app from qwen_agent.llm.schema import ASSISTANT, FUNCTION from pydantic import BaseModel, Field @@ -1029,163 +1031,18 @@ def build_directory_tree(path: str, relative_path: str = "") -> dict: return tree -@app.get("/api/v1/projects/tree") -async def get_projects_tree( - include_files: bool = True, - max_depth: int = 10, - filter_type: Optional[str] = None -): - """ - 获取projects文件夹的目录树结构 - - Args: - include_files: 是否包含文件,false时只显示目录 - max_depth: 最大深度限制 - filter_type: 过滤类型 ('data', 'robot', 'uploads') - - Returns: - dict: 包含目录树结构的响应 - """ - try: - projects_dir = "projects" - - if not os.path.exists(projects_dir): - return { - "success": False, - "message": "projects目录不存在", - "tree": {} - } - - tree = build_directory_tree(projects_dir) - - # 根据filter_type过滤 - if filter_type and filter_type in ['data', 'robot', 'uploads']: - filtered_children = [] - for child in tree.get("children", []): - if child["name"] == filter_type: - filtered_children.append(child) - tree["children"] = filtered_children - - # 如果不包含文件,移除所有文件节点 - if not include_files: - tree = filter_directories_only(tree) - - # 计算统计信息 - stats = calculate_tree_stats(tree) - - return { - "success": True, - "message": "目录树获取成功", - "tree": tree, - "stats": stats, - "filters": { - "include_files": include_files, - "max_depth": max_depth, - "filter_type": filter_type - } - } - - except Exception as e: - print(f"Error getting projects tree: {str(e)}") - raise HTTPException(status_code=500, detail=f"获取目录树失败: {str(e)}") -def filter_directories_only(tree: dict) -> dict: - """过滤掉文件,只保留目录""" - if tree["type"] != "directory": - return tree - - filtered_children = [] - for child in tree.get("children", []): - if child["type"] == "directory": - filtered_children.append(filter_directories_only(child)) - - tree["children"] = filtered_children - return tree - - -def calculate_tree_stats(tree: dict) -> dict: - """计算目录树统计信息""" - stats = { - "total_directories": 0, - "total_files": 0, - "total_size": 0 - } - - def traverse(node): - if node["type"] == "directory": - stats["total_directories"] += 1 - for child in node.get("children", []): - traverse(child) - else: - stats["total_files"] += 1 - stats["total_size"] += node.get("size", 0) - - traverse(tree) - return stats - - -@app.get("/api/v1/projects/subtree/{sub_path:path}") -async def get_projects_subtree( - sub_path: str, - include_files: bool = True, - max_depth: int = 5 -): - """ - 获取projects子目录的树结构 - - Args: - sub_path: 子目录路径,如 'data/1624be71-5432-40bf-9758-f4aecffd4e9c' - include_files: 是否包含文件 - max_depth: 最大深度 - - Returns: - dict: 包含子目录树结构的响应 - """ - try: - full_path = os.path.join("projects", sub_path) - - if not os.path.exists(full_path): - return { - "success": False, - "message": f"路径不存在: {sub_path}", - "tree": {} - } - - if not os.path.isdir(full_path): - return { - "success": False, - "message": f"路径不是目录: {sub_path}", - "tree": {} - } - - tree = build_directory_tree(full_path, sub_path) - - # 如果不包含文件,移除所有文件节点 - if not include_files: - tree = filter_directories_only(tree) - - # 计算统计信息 - stats = calculate_tree_stats(tree) - - return { - "success": True, - "message": "子目录树获取成功", - "sub_path": sub_path, - "tree": tree, - "stats": stats, - "filters": { - "include_files": include_files, - "max_depth": max_depth - } - } - - except Exception as e: - print(f"Error getting projects subtree: {str(e)}") - raise HTTPException(status_code=500, detail=f"获取子目录树失败: {str(e)}") +# 创建并挂载 WebDAV 应用 (无论以何种方式启动都要挂载) +webdav_app = create_webdav_app() +app.mount("/webdav", WSGIMiddleware(webdav_app)) if __name__ == "__main__": + # 启动 FastAPI 应用 (WebDAV 已经挂载到 /webdav 路径) + print("Starting FastAPI server with WebDAV support...") + print("WebDAV available at: http://localhost:8001/webdav") + print("API available at: http://localhost:8001") uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/poetry.lock b/poetry.lock index 5b6e807..e5d609c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -620,6 +620,18 @@ websocket-client = "*" [package.extras] tokenizer = ["tiktoken"] +[[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 = "distro" version = "1.9.0" @@ -3895,6 +3907,27 @@ docs = ["Sphinx (>=6.0)", "myst-parser (>=2.0.0)", "sphinx_rtd_theme (>=1.1.0)"] optional = ["python-socks", "wsaccel"] test = ["pytest", "websockets"] +[[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" @@ -4072,4 +4105,4 @@ propcache = ">=0.2.1" [metadata] lock-version = "2.1" python-versions = "3.12.0" -content-hash = "e9161f4f141b9dbc6037c5cfe89a044c33e0e556b420cc2df66528a1d3328f46" +content-hash = "8220c65ac40a6be66d2b743dc99682edb1528430873a8776c9b1d0af5ef13d0b" diff --git a/pyproject.toml b/pyproject.toml index 9597f2c..a49d26f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "openpyxl>=3.0.0", "xlrd>=2.0.0", "chardet>=5.0.0", + "wsgidav>=4.0.0", ] diff --git a/requirements.txt b/requirements.txt index 78115f1..88a07c3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -13,6 +13,7 @@ charset-normalizer==3.4.4 click==8.3.0 cryptography==46.0.3 dashscope==1.24.6 +defusedxml==0.7.1 distro==1.9.0 dotenv==0.9.9 et_xmlfile==2.0.0 @@ -94,6 +95,7 @@ tzdata==2025.2 urllib3==2.5.0 uvicorn==0.35.0 websocket-client==1.9.0 +WsgiDAV==4.3.3 xlrd==2.0.2 xlsxwriter==3.2.9 yarl==1.22.0 diff --git a/webdav_server.py b/webdav_server.py new file mode 100644 index 0000000..17cc169 --- /dev/null +++ b/webdav_server.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +""" +WebDAV 服务器模块 +提供基于 WsgiDAV 的文件管理服务 +""" + +import os +from pathlib import Path +from wsgidav.wsgidav_app import WsgiDAVApp +from wsgidav.fs_dav_provider import FilesystemProvider +from wsgidav.http_authenticator import HTTPAuthenticator + + +class NullDomainController: + """空域控制器,跳过认证""" + + def __init__(self, wsgidav_app, config): + pass + + def get_domain_realm(self, path_info, environ): + return None + + def require_authentication(self, realm, environ): + return False # 不需要认证 + + def is_authenticated_user(self, realm, user_name, environ): + return True # 总是认证成功 + + def auth_user(self, realm, user_name, password, environ): + return True # 总是认证成功 + + def supports_http_digest_auth(self): + return True + + def is_share_anonymous(self, share): + return True # 允许匿名访问 + + +def create_webdav_app(): + """ + 创建 WebDAV 应用 + + Returns: + WsgiDAVApp: 配置好的 WebDAV 应用实例 + """ + # 确保projects目录存在 + projects_dir = Path("projects") + projects_dir.mkdir(exist_ok=True) + + # 配置 WebDAV + config = { + "host": "0.0.0.0", + "port": 8001, + "provider_mapping": { + "/": FilesystemProvider(str(projects_dir)) + }, + "http_authenticator": { + "domain_controller": "webdav_server.NullDomainController", + "accept_basic": False, + "accept_digest": False, + "default_to_digest": False, + }, + "verbose": 1, # 日志级别 + "dir_browser": { + "enable": True, + "response_trailer": True, + "davmount_path": "/webdav", + "ms_mount_path": "/webdav", + } + } + + # 创建 WsgiDAV 应用 + app = WsgiDAVApp(config) + + return app + + +class ProjectWebDAVProvider(FilesystemProvider): + """ + 自定义的 WebDAV 提供器,专门用于管理 projects 目录 + """ + + def __init__(self, root_folder="projects"): + super().__init__(root_folder) + self.root_folder = Path(root_folder) + + def get_file_info(self, path): + """获取文件信息""" + full_path = self.root_folder / path.lstrip('/') + + if not full_path.exists(): + return None + + stat = full_path.stat() + return { + "path": path, + "name": full_path.name, + "is_dir": full_path.is_dir(), + "size": stat.st_size if full_path.is_file() else 0, + "modified": stat.st_mtime, + "created": stat.st_ctime + } + + def create_directory(self, path, **kwargs): + """创建目录""" + full_path = self.root_folder / path.lstrip('/') + full_path.mkdir(parents=True, exist_ok=True) + return True + + def delete_file(self, path): + """删除文件""" + full_path = self.root_folder / path.lstrip('/') + + if full_path.is_file(): + full_path.unlink() + elif full_path.is_dir(): + import shutil + shutil.rmtree(full_path) + + return True + + def move_file(self, src_path, dest_path): + """移动文件""" + src_full = self.root_folder / src_path.lstrip('/') + dest_full = self.root_folder / dest_path.lstrip('/') + + # 确保目标目录存在 + dest_full.parent.mkdir(parents=True, exist_ok=True) + + import shutil + shutil.move(str(src_full), str(dest_full)) + return True + + def copy_file(self, src_path, dest_path): + """复制文件""" + src_full = self.root_folder / src_path.lstrip('/') + dest_full = self.root_folder / dest_path.lstrip('/') + + # 确保目标目录存在 + dest_full.parent.mkdir(parents=True, exist_ok=True) + + import shutil + if src_full.is_file(): + shutil.copy2(str(src_full), str(dest_full)) + elif src_full.is_dir(): + shutil.copytree(str(src_full), str(dest_full)) + + return True + + +def start_webdav_server(host="0.0.0.0", port=8090): + """ + 启动独立的 WebDAV 服务器 + + Args: + host: 主机地址 + port: 端口号 + """ + app = create_webdav_app() + + # 修改配置,使用不同端口避免冲突 + app.config["host"] = host + app.config["port"] = port + + print(f"Starting WebDAV server on http://{host}:{port}/webdav") + print(f"Projects directory: {Path.cwd()}/projects") + + # 启动服务器 + from wsgidav.server.run_server import run_server + run_server(app) + + +if __name__ == "__main__": + start_webdav_server() \ No newline at end of file