add webdav support
This commit is contained in:
parent
49034bc571
commit
7039bec61a
139
routes/webdav.py
139
routes/webdav.py
@ -45,29 +45,42 @@ auth_dep = [Depends(verify_credentials)]
|
|||||||
DAV_NS = "DAV:"
|
DAV_NS = "DAV:"
|
||||||
DAV_PREFIX = "{DAV:}"
|
DAV_PREFIX = "{DAV:}"
|
||||||
|
|
||||||
ROBOT_BASE_DIR = Path("projects/robot")
|
PROJECTS_BASE_DIR = Path("projects")
|
||||||
|
ALLOWED_RESOURCE_TYPES = {"robot", "dataset"}
|
||||||
|
|
||||||
|
# 确保基础目录存在
|
||||||
|
for resource_type in ALLOWED_RESOURCE_TYPES:
|
||||||
|
(PROJECTS_BASE_DIR / resource_type).mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def get_bot_root(bot_id: str) -> Path:
|
def get_resource_root(resource_type: str, resource_id: str) -> Path:
|
||||||
"""获取 bot 的根目录,确保安全"""
|
"""获取资源的根目录,确保安全"""
|
||||||
|
# 验证资源类型
|
||||||
|
if resource_type not in ALLOWED_RESOURCE_TYPES:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=400,
|
||||||
|
detail=f"无效的资源类型,仅支持: {', '.join(ALLOWED_RESOURCE_TYPES)}"
|
||||||
|
)
|
||||||
|
|
||||||
# 防止路径遍历
|
# 防止路径遍历
|
||||||
if ".." in bot_id or "/" in bot_id or "\\" in bot_id:
|
if ".." in resource_id or "/" in resource_id or "\\" in resource_id:
|
||||||
raise HTTPException(status_code=400, detail="无效的 bot_id")
|
raise HTTPException(status_code=400, detail="无效的资源 ID")
|
||||||
root = ROBOT_BASE_DIR / bot_id
|
|
||||||
|
root = PROJECTS_BASE_DIR / resource_type / resource_id
|
||||||
root.mkdir(parents=True, exist_ok=True)
|
root.mkdir(parents=True, exist_ok=True)
|
||||||
return root
|
return root
|
||||||
|
|
||||||
|
|
||||||
def resolve_safe_path(bot_root: Path, rel_path: str) -> Path:
|
def resolve_safe_path(resource_root: Path, rel_path: str) -> Path:
|
||||||
"""安全地解析相对路径,防止目录遍历"""
|
"""安全地解析相对路径,防止目录遍历"""
|
||||||
# 规范化,去掉开头的 /
|
# 规范化,去掉开头的 /
|
||||||
rel_path = rel_path.strip("/")
|
rel_path = rel_path.strip("/")
|
||||||
if not rel_path:
|
if not rel_path:
|
||||||
return bot_root
|
return resource_root
|
||||||
target = (bot_root / rel_path).resolve()
|
target = (resource_root / rel_path).resolve()
|
||||||
# 确保在 bot_root 内
|
# 确保在 resource_root 内
|
||||||
try:
|
try:
|
||||||
target.relative_to(bot_root.resolve())
|
target.relative_to(resource_root.resolve())
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=403, detail="访问被拒绝")
|
raise HTTPException(status_code=403, detail="访问被拒绝")
|
||||||
return target
|
return target
|
||||||
@ -130,18 +143,18 @@ def build_multistatus(responses: list[ET.Element]) -> str:
|
|||||||
|
|
||||||
|
|
||||||
# ===== PROPFIND =====
|
# ===== PROPFIND =====
|
||||||
@router.api_route("/{bot_id}/{path:path}", methods=["PROPFIND"], dependencies=auth_dep)
|
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["PROPFIND"], dependencies=auth_dep)
|
||||||
@router.api_route("/{bot_id}", methods=["PROPFIND"], dependencies=auth_dep)
|
@router.api_route("/{resource_type}/{resource_id}", methods=["PROPFIND"], dependencies=auth_dep)
|
||||||
async def propfind(bot_id: str, request: Request, path: str = ""):
|
async def propfind(resource_type: str, resource_id: str, request: Request, path: str = ""):
|
||||||
"""PROPFIND - 获取资源属性(列出目录)"""
|
"""PROPFIND - 获取资源属性(列出目录)"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
target = resolve_safe_path(bot_root, path)
|
target = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="资源不存在")
|
raise HTTPException(status_code=404, detail="资源不存在")
|
||||||
|
|
||||||
depth = request.headers.get("Depth", "1")
|
depth = request.headers.get("Depth", "1")
|
||||||
base_href = f"/webdav/{bot_id}"
|
base_href = f"/webdav/{resource_type}/{resource_id}"
|
||||||
|
|
||||||
responses = []
|
responses = []
|
||||||
|
|
||||||
@ -175,9 +188,9 @@ async def propfind(bot_id: str, request: Request, path: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
# ===== OPTIONS =====
|
# ===== OPTIONS =====
|
||||||
@router.api_route("/{bot_id}/{path:path}", methods=["OPTIONS"])
|
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["OPTIONS"])
|
||||||
@router.api_route("/{bot_id}", methods=["OPTIONS"])
|
@router.api_route("/{resource_type}/{resource_id}", methods=["OPTIONS"])
|
||||||
async def options(bot_id: str, path: str = ""):
|
async def options(resource_type: str, resource_id: str, path: str = ""):
|
||||||
"""OPTIONS - 返回支持的 WebDAV 方法"""
|
"""OPTIONS - 返回支持的 WebDAV 方法"""
|
||||||
return Response(
|
return Response(
|
||||||
status_code=200,
|
status_code=200,
|
||||||
@ -189,11 +202,11 @@ async def options(bot_id: str, path: str = ""):
|
|||||||
|
|
||||||
|
|
||||||
# ===== GET =====
|
# ===== GET =====
|
||||||
@router.get("/{bot_id}/{path:path}", dependencies=auth_dep)
|
@router.get("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
|
||||||
async def get_file(bot_id: str, path: str):
|
async def get_file(resource_type: str, resource_id: str, path: str):
|
||||||
"""GET - 下载文件"""
|
"""GET - 下载文件"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
target = resolve_safe_path(bot_root, path)
|
target = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="资源不存在")
|
raise HTTPException(status_code=404, detail="资源不存在")
|
||||||
@ -205,7 +218,7 @@ async def get_file(bot_id: str, path: str):
|
|||||||
if item.name.startswith("."):
|
if item.name.startswith("."):
|
||||||
continue
|
continue
|
||||||
name = item.name + ("/" if item.is_dir() else "")
|
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}"
|
rel_href = f"/webdav/{resource_type}/{resource_id}/{path.strip('/')}/{item.name}" if path.strip("/") else f"/webdav/{resource_type}/{resource_id}/{item.name}"
|
||||||
items.append(f'<li><a href="{rel_href}">{name}</a></li>')
|
items.append(f'<li><a href="{rel_href}">{name}</a></li>')
|
||||||
html = f"<html><body><h1>Index of /{path}</h1><ul>{''.join(items)}</ul></body></html>"
|
html = f"<html><body><h1>Index of /{path}</h1><ul>{''.join(items)}</ul></body></html>"
|
||||||
return Response(content=html, media_type="text/html")
|
return Response(content=html, media_type="text/html")
|
||||||
@ -215,11 +228,11 @@ async def get_file(bot_id: str, path: str):
|
|||||||
|
|
||||||
|
|
||||||
# ===== HEAD =====
|
# ===== HEAD =====
|
||||||
@router.head("/{bot_id}/{path:path}", dependencies=auth_dep)
|
@router.head("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
|
||||||
async def head_file(bot_id: str, path: str):
|
async def head_file(resource_type: str, resource_id: str, path: str):
|
||||||
"""HEAD - 获取文件元数据"""
|
"""HEAD - 获取文件元数据"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
target = resolve_safe_path(bot_root, path)
|
target = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="资源不存在")
|
raise HTTPException(status_code=404, detail="资源不存在")
|
||||||
@ -237,11 +250,11 @@ async def head_file(bot_id: str, path: str):
|
|||||||
|
|
||||||
|
|
||||||
# ===== PUT =====
|
# ===== PUT =====
|
||||||
@router.put("/{bot_id}/{path:path}", dependencies=auth_dep)
|
@router.put("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
|
||||||
async def put_file(bot_id: str, path: str, request: Request):
|
async def put_file(resource_type: str, resource_id: str, path: str, request: Request):
|
||||||
"""PUT - 上传/覆盖文件"""
|
"""PUT - 上传/覆盖文件"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
target = resolve_safe_path(bot_root, path)
|
target = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
# 确保父目录存在
|
# 确保父目录存在
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@ -252,19 +265,19 @@ async def put_file(bot_id: str, path: str, request: Request):
|
|||||||
with open(target, "wb") as f:
|
with open(target, "wb") as f:
|
||||||
f.write(body)
|
f.write(body)
|
||||||
|
|
||||||
logger.info(f"WebDAV PUT: {bot_id}/{path} ({len(body)} bytes)")
|
logger.info(f"WebDAV PUT: {resource_type}/{resource_id}/{path} ({len(body)} bytes)")
|
||||||
return Response(status_code=201 if is_new else 204)
|
return Response(status_code=201 if is_new else 204)
|
||||||
|
|
||||||
|
|
||||||
# ===== DELETE =====
|
# ===== DELETE =====
|
||||||
@router.delete("/{bot_id}/{path:path}", dependencies=auth_dep)
|
@router.delete("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
|
||||||
async def delete_resource(bot_id: str, path: str):
|
async def delete_resource(resource_type: str, resource_id: str, path: str):
|
||||||
"""DELETE - 删除文件或目录"""
|
"""DELETE - 删除文件或目录"""
|
||||||
if not path.strip("/"):
|
if not path.strip("/"):
|
||||||
raise HTTPException(status_code=403, detail="不能删除 bot 根目录")
|
raise HTTPException(status_code=403, detail="不能删除资源根目录")
|
||||||
|
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
target = resolve_safe_path(bot_root, path)
|
target = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if not target.exists():
|
if not target.exists():
|
||||||
raise HTTPException(status_code=404, detail="资源不存在")
|
raise HTTPException(status_code=404, detail="资源不存在")
|
||||||
@ -274,16 +287,16 @@ async def delete_resource(bot_id: str, path: str):
|
|||||||
else:
|
else:
|
||||||
shutil.rmtree(target)
|
shutil.rmtree(target)
|
||||||
|
|
||||||
logger.info(f"WebDAV DELETE: {bot_id}/{path}")
|
logger.info(f"WebDAV DELETE: {resource_type}/{resource_id}/{path}")
|
||||||
return Response(status_code=204)
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
# ===== MKCOL =====
|
# ===== MKCOL =====
|
||||||
@router.api_route("/{bot_id}/{path:path}", methods=["MKCOL"], dependencies=auth_dep)
|
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["MKCOL"], dependencies=auth_dep)
|
||||||
async def mkcol(bot_id: str, path: str):
|
async def mkcol(resource_type: str, resource_id: str, path: str):
|
||||||
"""MKCOL - 创建目录"""
|
"""MKCOL - 创建目录"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
target = resolve_safe_path(bot_root, path)
|
target = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if target.exists():
|
if target.exists():
|
||||||
raise HTTPException(status_code=405, detail="资源已存在")
|
raise HTTPException(status_code=405, detail="资源已存在")
|
||||||
@ -292,16 +305,16 @@ async def mkcol(bot_id: str, path: str):
|
|||||||
raise HTTPException(status_code=409, detail="父目录不存在")
|
raise HTTPException(status_code=409, detail="父目录不存在")
|
||||||
|
|
||||||
target.mkdir()
|
target.mkdir()
|
||||||
logger.info(f"WebDAV MKCOL: {bot_id}/{path}")
|
logger.info(f"WebDAV MKCOL: {resource_type}/{resource_id}/{path}")
|
||||||
return Response(status_code=201)
|
return Response(status_code=201)
|
||||||
|
|
||||||
|
|
||||||
# ===== COPY =====
|
# ===== COPY =====
|
||||||
@router.api_route("/{bot_id}/{path:path}", methods=["COPY"], dependencies=auth_dep)
|
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["COPY"], dependencies=auth_dep)
|
||||||
async def copy_resource(bot_id: str, path: str, request: Request):
|
async def copy_resource(resource_type: str, resource_id: str, path: str, request: Request):
|
||||||
"""COPY - 复制文件或目录"""
|
"""COPY - 复制文件或目录"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
source = resolve_safe_path(bot_root, path)
|
source = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if not source.exists():
|
if not source.exists():
|
||||||
raise HTTPException(status_code=404, detail="源资源不存在")
|
raise HTTPException(status_code=404, detail="源资源不存在")
|
||||||
@ -311,8 +324,8 @@ async def copy_resource(bot_id: str, path: str, request: Request):
|
|||||||
raise HTTPException(status_code=400, detail="缺少 Destination 头")
|
raise HTTPException(status_code=400, detail="缺少 Destination 头")
|
||||||
|
|
||||||
# 解析 Destination,提取相对路径
|
# 解析 Destination,提取相对路径
|
||||||
dest_path = _parse_destination(destination, bot_id)
|
dest_path = _parse_destination(destination, resource_type, resource_id)
|
||||||
dest_target = resolve_safe_path(bot_root, dest_path)
|
dest_target = resolve_safe_path(resource_root, dest_path)
|
||||||
|
|
||||||
overwrite = request.headers.get("Overwrite", "T").upper() == "T"
|
overwrite = request.headers.get("Overwrite", "T").upper() == "T"
|
||||||
existed = dest_target.exists()
|
existed = dest_target.exists()
|
||||||
@ -333,16 +346,16 @@ async def copy_resource(bot_id: str, path: str, request: Request):
|
|||||||
else:
|
else:
|
||||||
shutil.copytree(str(source), str(dest_target))
|
shutil.copytree(str(source), str(dest_target))
|
||||||
|
|
||||||
logger.info(f"WebDAV COPY: {bot_id}/{path} -> {dest_path}")
|
logger.info(f"WebDAV COPY: {resource_type}/{resource_id}/{path} -> {dest_path}")
|
||||||
return Response(status_code=204 if existed else 201)
|
return Response(status_code=204 if existed else 201)
|
||||||
|
|
||||||
|
|
||||||
# ===== MOVE =====
|
# ===== MOVE =====
|
||||||
@router.api_route("/{bot_id}/{path:path}", methods=["MOVE"], dependencies=auth_dep)
|
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["MOVE"], dependencies=auth_dep)
|
||||||
async def move_resource(bot_id: str, path: str, request: Request):
|
async def move_resource(resource_type: str, resource_id: str, path: str, request: Request):
|
||||||
"""MOVE - 移动/重命名文件或目录"""
|
"""MOVE - 移动/重命名文件或目录"""
|
||||||
bot_root = get_bot_root(bot_id)
|
resource_root = get_resource_root(resource_type, resource_id)
|
||||||
source = resolve_safe_path(bot_root, path)
|
source = resolve_safe_path(resource_root, path)
|
||||||
|
|
||||||
if not source.exists():
|
if not source.exists():
|
||||||
raise HTTPException(status_code=404, detail="源资源不存在")
|
raise HTTPException(status_code=404, detail="源资源不存在")
|
||||||
@ -351,8 +364,8 @@ async def move_resource(bot_id: str, path: str, request: Request):
|
|||||||
if not destination:
|
if not destination:
|
||||||
raise HTTPException(status_code=400, detail="缺少 Destination 头")
|
raise HTTPException(status_code=400, detail="缺少 Destination 头")
|
||||||
|
|
||||||
dest_path = _parse_destination(destination, bot_id)
|
dest_path = _parse_destination(destination, resource_type, resource_id)
|
||||||
dest_target = resolve_safe_path(bot_root, dest_path)
|
dest_target = resolve_safe_path(resource_root, dest_path)
|
||||||
|
|
||||||
overwrite = request.headers.get("Overwrite", "T").upper() == "T"
|
overwrite = request.headers.get("Overwrite", "T").upper() == "T"
|
||||||
existed = dest_target.exists()
|
existed = dest_target.exists()
|
||||||
@ -369,20 +382,20 @@ async def move_resource(bot_id: str, path: str, request: Request):
|
|||||||
dest_target.parent.mkdir(parents=True, exist_ok=True)
|
dest_target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
shutil.move(str(source), str(dest_target))
|
shutil.move(str(source), str(dest_target))
|
||||||
|
|
||||||
logger.info(f"WebDAV MOVE: {bot_id}/{path} -> {dest_path}")
|
logger.info(f"WebDAV MOVE: {resource_type}/{resource_id}/{path} -> {dest_path}")
|
||||||
return Response(status_code=204 if existed else 201)
|
return Response(status_code=204 if existed else 201)
|
||||||
|
|
||||||
|
|
||||||
def _parse_destination(destination: str, bot_id: str) -> str:
|
def _parse_destination(destination: str, resource_type: str, resource_id: str) -> str:
|
||||||
"""从 Destination 头中提取相对路径"""
|
"""从 Destination 头中提取相对路径"""
|
||||||
# Destination 格式: http://host/webdav/{bot_id}/some/path
|
# Destination 格式: http://host/webdav/{resource_type}/{resource_id}/some/path
|
||||||
from urllib.parse import urlparse, unquote
|
from urllib.parse import urlparse, unquote
|
||||||
parsed = urlparse(destination)
|
parsed = urlparse(destination)
|
||||||
path = unquote(parsed.path)
|
path = unquote(parsed.path)
|
||||||
prefix = f"/webdav/{bot_id}/"
|
prefix = f"/webdav/{resource_type}/{resource_id}/"
|
||||||
if path.startswith(prefix):
|
if path.startswith(prefix):
|
||||||
return path[len(prefix):]
|
return path[len(prefix):]
|
||||||
prefix_no_slash = f"/webdav/{bot_id}"
|
prefix_no_slash = f"/webdav/{resource_type}/{resource_id}"
|
||||||
if path == prefix_no_slash:
|
if path == prefix_no_slash:
|
||||||
return ""
|
return ""
|
||||||
raise HTTPException(status_code=400, detail="无效的 Destination 路径")
|
raise HTTPException(status_code=400, detail="无效的 Destination 路径")
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user