remove resource_id

This commit is contained in:
朱潮 2026-03-09 15:48:35 +08:00
parent 7039bec61a
commit 9fd4a099da

View File

@ -1,9 +1,9 @@
"""
WebDAV 文件管理路由
projects/robot/{bot_id} 目录提供 WebDAV 协议支持
projects/{resource_type}/ 目录提供 WebDAV 协议支持
支持的资源类型: robot, dataset
"""
import os
import secrets
import shutil
import mimetypes
@ -12,7 +12,7 @@ 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, Response as FastAPIResponse
from fastapi.responses import FileResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
import logging
@ -53,34 +53,24 @@ for resource_type in ALLOWED_RESOURCE_TYPES:
(PROJECTS_BASE_DIR / resource_type).mkdir(parents=True, exist_ok=True)
def get_resource_root(resource_type: str, resource_id: str) -> 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)}"
)
# 防止路径遍历
if ".." in resource_id or "/" in resource_id or "\\" in resource_id:
raise HTTPException(status_code=400, detail="无效的资源 ID")
root = PROJECTS_BASE_DIR / resource_type / resource_id
root.mkdir(parents=True, exist_ok=True)
return root
return PROJECTS_BASE_DIR / resource_type
def resolve_safe_path(resource_root: Path, rel_path: str) -> Path:
def resolve_safe_path(root: Path, rel_path: str) -> Path:
"""安全地解析相对路径,防止目录遍历"""
# 规范化,去掉开头的 /
rel_path = rel_path.strip("/")
if not rel_path:
return resource_root
target = (resource_root / rel_path).resolve()
# 确保在 resource_root 内
return root
target = (root / rel_path).resolve()
try:
target.relative_to(resource_root.resolve())
target.relative_to(root.resolve())
except ValueError:
raise HTTPException(status_code=403, detail="访问被拒绝")
return target
@ -93,7 +83,6 @@ def format_http_date(dt: datetime) -> str:
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
@ -105,25 +94,19 @@ def get_file_props(file_path: Path, href: str) -> ET.Element:
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"
@ -142,30 +125,15 @@ def build_multistatus(responses: list[ET.Element]) -> str:
)
# ===== PROPFIND =====
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["PROPFIND"], dependencies=auth_dep)
@router.api_route("/{resource_type}/{resource_id}", methods=["PROPFIND"], dependencies=auth_dep)
async def propfind(resource_type: str, resource_id: str, request: Request, path: str = ""):
"""PROPFIND - 获取资源属性(列出目录)"""
resource_root = get_resource_root(resource_type, resource_id)
target = resolve_safe_path(resource_root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
depth = request.headers.get("Depth", "1")
base_href = f"/webdav/{resource_type}/{resource_id}"
def _collect_propfind_responses(root: Path, target: Path, base_href: str, rel: str, depth: str) -> list[ET.Element]:
"""收集 PROPFIND 响应"""
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()):
@ -179,6 +147,26 @@ async def propfind(resource_type: str, resource_id: str, request: Request, path:
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,
@ -188,9 +176,9 @@ async def propfind(resource_type: str, resource_id: str, request: Request, path:
# ===== OPTIONS =====
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["OPTIONS"])
@router.api_route("/{resource_type}/{resource_id}", methods=["OPTIONS"])
async def options(resource_type: str, resource_id: str, path: str = ""):
@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,
@ -202,25 +190,27 @@ async def options(resource_type: str, resource_id: str, path: str = ""):
# ===== GET =====
@router.get("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
async def get_file(resource_type: str, resource_id: str, path: str):
"""GET - 下载文件"""
resource_root = get_resource_root(resource_type, resource_id)
target = resolve_safe_path(resource_root, path)
@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():
# 对目录返回简单的 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/{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>')
html = f"<html><body><h1>Index of /{path}</h1><ul>{''.join(items)}</ul></body></html>"
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))
@ -228,11 +218,12 @@ async def get_file(resource_type: str, resource_id: str, path: str):
# ===== HEAD =====
@router.head("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
async def head_file(resource_type: str, resource_id: str, path: str):
@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 - 获取文件元数据"""
resource_root = get_resource_root(resource_type, resource_id)
target = resolve_safe_path(resource_root, path)
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
@ -250,13 +241,12 @@ async def head_file(resource_type: str, resource_id: str, path: str):
# ===== PUT =====
@router.put("/{resource_type}/{resource_id}/{path:path}", dependencies=auth_dep)
async def put_file(resource_type: str, resource_id: str, path: str, request: Request):
@router.put("/{resource_type}/{path:path}", dependencies=auth_dep)
async def put_file(resource_type: str, path: str, request: Request):
"""PUT - 上传/覆盖文件"""
resource_root = get_resource_root(resource_type, resource_id)
target = resolve_safe_path(resource_root, path)
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()
@ -265,19 +255,19 @@ async def put_file(resource_type: str, resource_id: str, path: str, request: Req
with open(target, "wb") as f:
f.write(body)
logger.info(f"WebDAV PUT: {resource_type}/{resource_id}/{path} ({len(body)} bytes)")
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}/{resource_id}/{path:path}", dependencies=auth_dep)
async def delete_resource(resource_type: str, resource_id: str, path: str):
@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="不能删除资源根目录")
resource_root = get_resource_root(resource_type, resource_id)
target = resolve_safe_path(resource_root, path)
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if not target.exists():
raise HTTPException(status_code=404, detail="资源不存在")
@ -287,16 +277,16 @@ async def delete_resource(resource_type: str, resource_id: str, path: str):
else:
shutil.rmtree(target)
logger.info(f"WebDAV DELETE: {resource_type}/{resource_id}/{path}")
logger.info(f"WebDAV DELETE: {resource_type}/{path}")
return Response(status_code=204)
# ===== MKCOL =====
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["MKCOL"], dependencies=auth_dep)
async def mkcol(resource_type: str, resource_id: str, path: str):
@router.api_route("/{resource_type}/{path:path}", methods=["MKCOL"], dependencies=auth_dep)
async def mkcol(resource_type: str, path: str):
"""MKCOL - 创建目录"""
resource_root = get_resource_root(resource_type, resource_id)
target = resolve_safe_path(resource_root, path)
root = get_resource_type_root(resource_type)
target = resolve_safe_path(root, path)
if target.exists():
raise HTTPException(status_code=405, detail="资源已存在")
@ -305,16 +295,16 @@ async def mkcol(resource_type: str, resource_id: str, path: str):
raise HTTPException(status_code=409, detail="父目录不存在")
target.mkdir()
logger.info(f"WebDAV MKCOL: {resource_type}/{resource_id}/{path}")
logger.info(f"WebDAV MKCOL: {resource_type}/{path}")
return Response(status_code=201)
# ===== COPY =====
@router.api_route("/{resource_type}/{resource_id}/{path:path}", methods=["COPY"], dependencies=auth_dep)
async def copy_resource(resource_type: str, resource_id: str, path: str, request: Request):
@router.api_route("/{resource_type}/{path:path}", methods=["COPY"], dependencies=auth_dep)
async def copy_resource(resource_type: str, path: str, request: Request):
"""COPY - 复制文件或目录"""
resource_root = get_resource_root(resource_type, resource_id)
source = resolve_safe_path(resource_root, path)
root = get_resource_type_root(resource_type)
source = resolve_safe_path(root, path)
if not source.exists():
raise HTTPException(status_code=404, detail="源资源不存在")
@ -323,9 +313,8 @@ async def copy_resource(resource_type: str, resource_id: str, path: str, request
if not destination:
raise HTTPException(status_code=400, detail="缺少 Destination 头")
# 解析 Destination提取相对路径
dest_path = _parse_destination(destination, resource_type, resource_id)
dest_target = resolve_safe_path(resource_root, dest_path)
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()
@ -346,16 +335,16 @@ async def copy_resource(resource_type: str, resource_id: str, path: str, request
else:
shutil.copytree(str(source), str(dest_target))
logger.info(f"WebDAV COPY: {resource_type}/{resource_id}/{path} -> {dest_path}")
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}/{resource_id}/{path:path}", methods=["MOVE"], dependencies=auth_dep)
async def move_resource(resource_type: str, resource_id: str, path: str, request: Request):
@router.api_route("/{resource_type}/{path:path}", methods=["MOVE"], dependencies=auth_dep)
async def move_resource(resource_type: str, path: str, request: Request):
"""MOVE - 移动/重命名文件或目录"""
resource_root = get_resource_root(resource_type, resource_id)
source = resolve_safe_path(resource_root, path)
root = get_resource_type_root(resource_type)
source = resolve_safe_path(root, path)
if not source.exists():
raise HTTPException(status_code=404, detail="源资源不存在")
@ -364,8 +353,8 @@ async def move_resource(resource_type: str, resource_id: str, path: str, request
if not destination:
raise HTTPException(status_code=400, detail="缺少 Destination 头")
dest_path = _parse_destination(destination, resource_type, resource_id)
dest_target = resolve_safe_path(resource_root, dest_path)
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()
@ -382,20 +371,19 @@ async def move_resource(resource_type: str, resource_id: str, path: str, request
dest_target.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(source), str(dest_target))
logger.info(f"WebDAV MOVE: {resource_type}/{resource_id}/{path} -> {dest_path}")
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, resource_id: str) -> str:
def _parse_destination(destination: str, resource_type: str) -> str:
"""从 Destination 头中提取相对路径"""
# Destination 格式: http://host/webdav/{resource_type}/{resource_id}/some/path
from urllib.parse import urlparse, unquote
parsed = urlparse(destination)
path = unquote(parsed.path)
prefix = f"/webdav/{resource_type}/{resource_id}/"
prefix = f"/webdav/{resource_type}/"
if path.startswith(prefix):
return path[len(prefix):]
prefix_no_slash = f"/webdav/{resource_type}/{resource_id}"
prefix_no_slash = f"/webdav/{resource_type}"
if path == prefix_no_slash:
return ""
raise HTTPException(status_code=400, detail="无效的 Destination 路径")