routes/webdav.py — 完全重写,从手写 WebDAV 协议改为使用 WsgiDAV 开源库
This commit is contained in:
parent
4ad1c96bf3
commit
5d97be9557
@ -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__":
|
||||
|
||||
47
poetry.lock
generated
47
poetry.lock
generated
@ -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"
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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"
|
||||
|
||||
408
routes/webdav.py
408
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 '<?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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user