Files
LitePan/webdav/server.py
T
2026-05-26 09:10:51 +08:00

1729 lines
71 KiB
Python
Executable File

"""FastAPI 版 WebDAV 服务器:统一走驱动层 + 全局缓存 + 代理/重定向两种下载模式。"""
import xml.etree.ElementTree as ET
import base64
import time
import asyncio
import os
import uuid
from datetime import datetime, timezone
from email.utils import parsedate_to_datetime
from pathlib import PurePosixPath
from typing import List, Tuple, Dict, Optional, Any
from urllib.parse import unquote, quote
from html import escape as html_escape
from fastapi import Request, Response, HTTPException
# 注册 DAV 命名空间前缀,避免 ElementTree 自动生成 ns0:/ns1: 导致部分客户端(PotPlayer 等)无法解析
ET.register_namespace('D', 'DAV:')
from database.db import db
from core.driver_service import (
get_account_driver,
get_effective_download_mode,
resolve_download,
)
from core.range_proxy import build_proxy_file_info_from_download, serve_range_proxy
from core.base import FileItem
from cache import get_global_cache, get_cache_cleaner
from core.log_manager import get_writer, LogModule
from config import config_manager
from core.operation_wrapper import current_account_id
from .utils import parse_webdav_path, generate_propfind_response, generate_directory_html, get_mime_type, WEBDAV_TIME_FORMAT, WEBDAV_ISO_TIME_FORMAT, ensure_utc
def _parse_db_timestamp(value: str) -> datetime:
"""将 SQLite TIMESTAMP 字符串转为 UTC datetime,解析失败回退到当前时间。"""
if not value:
return datetime.now(timezone.utc)
try:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc)
except ValueError:
return datetime.now(timezone.utc)
from core.utils import normalize_bool
def get_webdav_logger():
# 延迟拿 logger,模块 import 期 log 系统可能还没 ready
try:
return get_writer(LogModule.WEBDAV)
except RuntimeError:
class FallbackLogger:
def debug(self, msg, **kwargs): pass
def info(self, msg, **kwargs): pass
def warning(self, msg, **kwargs): print(f"[WebDAV] {msg}")
def error(self, msg, **kwargs): print(f"[WebDAV ERROR] {msg}")
return FallbackLogger()
webdav_logger = get_webdav_logger()
def get_cache_logger():
try:
return get_writer(LogModule.CACHE)
except RuntimeError:
class FallbackLogger:
def debug(self, msg, **kwargs): pass
def info(self, msg, **kwargs): pass
def warning(self, msg, **kwargs): print(f"[CACHE] {msg}")
def error(self, msg, **kwargs): print(f"[CACHE ERROR] {msg}")
return FallbackLogger()
cache_logger = get_cache_logger()
NS = {
'D': 'DAV:',
'xmlns:D': 'DAV:'
}
class FastAPIWebDAVServer:
def __init__(self, check_auth_func=None):
self.check_auth = check_auth_func
self.cache_manager = get_global_cache()
self.cache_cleaner = get_cache_cleaner()
def _is_macos_metadata_path(self, path: str) -> bool:
parts = [unquote(part) for part in (path or "").split("/") if part]
if not parts:
return False
name = parts[-1]
ignored_dirs = {".TemporaryItems", ".Trashes", ".Spotlight-V100", ".fseventsd"}
return (
name == ".DS_Store"
or name.startswith("._")
or any(part in ignored_dirs for part in parts)
)
async def _handle_macos_metadata_request(self, request: Request, method: str) -> Response:
if method == "PUT":
try:
await request.body()
except Exception:
pass
return Response(status_code=201)
if method in {"DELETE", "MOVE", "COPY"}:
return Response(status_code=204)
if method == "MKCOL":
return Response(status_code=201)
return Response(status_code=404)
def _normalize_cache_path(self, path: str) -> str:
normalized = "/" + (path or "").strip("/")
return "/" if normalized == "/" else normalized.rstrip("/")
def _format_http_datetime(self, value: Optional[datetime]) -> str:
if value:
return ensure_utc(value).strftime(WEBDAV_TIME_FORMAT)
return datetime.now(timezone.utc).strftime(WEBDAV_TIME_FORMAT)
def _build_etag(self, file_info: FileItem) -> str:
modified_part = ""
if file_info.modified:
try:
modified_part = f"-{int(file_info.modified.timestamp())}"
except Exception:
modified_part = ""
# WebDAV 驱动的 id 可能含中文,HTTP 头按 latin-1 编码,这里必须 percent-encode
safe_id = quote(str(file_info.id), safe="")
return f"\"{safe_id}-{file_info.size}{modified_part}\""
def _parse_single_range(self, range_header: str, total_size: int) -> Tuple[int, int]:
if total_size <= 0:
raise ValueError("empty")
header_value = (range_header or "").strip()
if not header_value.startswith("bytes="):
raise ValueError("unit")
ranges_str = header_value[len("bytes="):].strip()
parts = [part.strip() for part in ranges_str.split(",") if part.strip()]
if len(parts) != 1:
raise ValueError("multiple")
part = parts[0]
if "-" not in part:
raise ValueError("format")
start_str, end_str = part.split("-", 1)
if start_str == "":
suffix_length = int(end_str) if end_str else 0
if suffix_length <= 0:
raise ValueError("suffix")
if suffix_length >= total_size:
return 0, total_size - 1
return total_size - suffix_length, total_size - 1
start = int(start_str)
if start < 0:
raise ValueError("start")
if start >= total_size:
raise ValueError("unsat")
if end_str == "":
return start, total_size - 1
end = int(end_str)
if end < start:
raise ValueError("order")
if end >= total_size:
end = total_size - 1
return start, end
def _match_if_none_match(self, request: Request, etag: str) -> bool:
value = (request.headers.get("If-None-Match") or "").strip()
if not value:
return False
if value == "*":
return True
candidates = [item.strip() for item in value.split(",") if item.strip()]
return etag in candidates
def _match_if_modified_since(self, request: Request, last_modified: Optional[datetime]) -> bool:
value = (request.headers.get("If-Modified-Since") or "").strip()
if not value or not last_modified:
return False
try:
since = parsedate_to_datetime(value)
if since.tzinfo:
since = since.astimezone(tz=None).replace(tzinfo=None)
return last_modified.replace(microsecond=0) <= since.replace(microsecond=0)
except Exception:
return False
def _build_not_modified_response(self, etag: str, last_modified: Optional[datetime]) -> Response:
headers = {"ETag": etag}
if last_modified:
headers["Last-Modified"] = self._format_http_datetime(last_modified)
return Response(status_code=304, headers=headers)
async def _is_webdav_metadata_cache_enabled(self) -> bool:
try:
value = await config_manager.get_async("webdav_cache_enabled")
return normalize_bool(value, True)
except Exception:
return True
async def _is_webdav_path_cache_enabled(self) -> bool:
try:
value = await config_manager.get_async("webdav_cache_enabled")
return normalize_bool(value, True)
except Exception:
return True
async def handle_request(self, request: Request, path: str = "") -> Response:
try:
if not await self._check_authentication(request):
return Response(
status_code=401,
headers={'WWW-Authenticate': 'Basic realm="LitePan v3.1 WebDAV"'}
)
method = request.method.upper()
path = '/' + path.strip('/')
handlers = {
'OPTIONS': self._handle_options,
'PROPFIND': self._handle_propfind,
'GET': self._handle_get,
'HEAD': self._handle_head,
'PUT': self._handle_put,
'DELETE': self._handle_delete,
'MKCOL': self._handle_mkcol,
'MOVE': self._handle_move,
'COPY': self._handle_copy,
}
handler = handlers.get(method)
if not handler:
return Response(
status_code=405,
headers={'Allow': ', '.join(handlers.keys())}
)
if method != "OPTIONS" and self._is_macos_metadata_path(path):
return await self._handle_macos_metadata_request(request, method)
try:
return await handler(request, path)
except (ConnectionResetError, BrokenPipeError, OSError, asyncio.CancelledError) as e:
webdav_logger.debug(f"WebDAV客户端连接断开: {type(e).__name__}: {e}")
return Response(status_code=499, content="Client Disconnected")
except Exception as e:
webdav_logger.error(f"WebDAV处理错误: {e}", exc_info=True)
return Response(status_code=500, content=str(e))
except Exception as e:
webdav_logger.error(f"WebDAV请求处理异常: {e}", exc_info=True)
return Response(status_code=500, content=f"Internal Server Error: {str(e)}")
async def _check_authentication(self, request: Request) -> bool:
"""WebDAV Basic Auth:直接复用管理员用户名/密码,密码必须已哈希。"""
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Basic '):
return False
try:
credentials = base64.b64decode(auth_header[6:]).decode('utf-8')
username, password = credentials.split(':', 1)
admin_username = config_manager.get('admin_username')
admin_password = config_manager.get('admin_password')
# 兼容历史把值存成带引号 JSON 字符串的情况
if isinstance(admin_username, str) and admin_username.startswith('"') and admin_username.endswith('"'):
admin_username = admin_username[1:-1]
if isinstance(admin_password, str) and admin_password.startswith('"') and admin_password.endswith('"'):
admin_password = admin_password[1:-1]
if not admin_username or not admin_password:
return False
if username != admin_username:
webdav_logger.warning(f"WebDAV认证失败: 用户名不匹配 {username}")
return False
password_match = False
webdav_logger.debug(f"WebDAV认证尝试: 用户名={username}")
try:
if admin_password.startswith('scrypt:') or admin_password.startswith('pbkdf2:'):
from core.security import check_password_hash
password_match = check_password_hash(admin_password, password)
if password_match:
webdav_logger.debug("标准哈希密码匹配成功")
else:
# 明文密码直接拒绝,避免和旧配置产生安全误判
webdav_logger.warning("管理员密码未哈希化,出于安全原因拒绝访问")
except Exception as hash_error:
webdav_logger.debug(f"哈希密码验证失败: {hash_error}")
if password_match:
webdav_logger.debug(f"WebDAV认证成功: 用户={username}")
else:
webdav_logger.warning(f"WebDAV认证失败: 用户={username}, 密码错误")
return password_match
except Exception as e:
webdav_logger.error(f"WebDAV认证失败: {e}")
return False
async def _handle_options(self, request: Request, path: str) -> Response:
return Response(
status_code=200,
headers={
'Allow': 'OPTIONS, PROPFIND, GET, HEAD, PUT, DELETE, MKCOL, MOVE, COPY',
'DAV': '1, 2',
'MS-Author-Via': 'DAV'
}
)
async def _handle_propfind(self, request: Request, path: str) -> Response:
try:
depth = request.headers.get('Depth', 'infinity')
account_name, file_path = parse_webdav_path(path)
if not account_name:
resource = await self._get_resource(path)
if not resource:
return Response(status_code=404, content="Resource not found")
multistatus = ET.Element('{DAV:}multistatus')
await self._add_response_element(multistatus, path, resource)
if resource['is_collection'] and depth != '0':
children = await self._list_children(path)
for child_name, child_resource in children:
child_path = path.rstrip('/') + '/' + child_name
await self._add_response_element(multistatus, child_path, child_resource)
xml_str = ET.tostring(multistatus, encoding='unicode', xml_declaration=True)
return Response(
content=xml_str,
status_code=207,
headers={'Content-Type': 'application/xml; charset=utf-8'}
)
context = await self._get_account_context_by_name(account_name)
if not context:
return Response(status_code=404, content="Account not found")
account_id = context["account_id"]
cache_key_path = f"PROPFIND|{path}|depth={depth}"
if self.cache_manager and await self._is_webdav_metadata_cache_enabled():
cached_xml = await self.cache_manager.get_webdav_metadata_cache(account_id, cache_key_path)
if isinstance(cached_xml, str) and cached_xml:
return Response(
content=cached_xml.encode("utf-8"),
status_code=207,
headers={'Content-Type': 'application/xml; charset=utf-8'}
)
driver = context["driver"]
account_info = {
"id": account_id,
"name": context.get("name") or account_name,
"driver_type": context.get("driver_type"),
"config": context.get("config"),
}
if file_path in ("/", ""):
file_info = FileItem(
id="0",
name=account_name,
path="/",
size=0,
is_dir=True,
modified=datetime.now(timezone.utc),
created=datetime.now(timezone.utc),
)
resource = {
"is_collection": True,
"file_info": file_info,
"driver": driver,
"account": account_info,
}
children_parent_id = "0"
else:
file_info = await self._resolve_path_to_file(driver, file_path, account_id=account_id)
if not file_info:
return Response(status_code=404, content="Resource not found")
resource = {
"is_collection": file_info.is_dir,
"file_info": file_info,
"driver": driver,
"account": account_info,
}
children_parent_id = str(file_info.id)
multistatus = ET.Element('{DAV:}multistatus')
await self._add_response_element(multistatus, path, resource)
if resource["is_collection"] and depth != "0":
files = await driver.list_files(children_parent_id)
for child in files:
child_path = path.rstrip('/') + '/' + child.name
await self._add_response_element(
multistatus,
child_path,
{
"is_collection": child.is_dir,
"file_info": child,
"driver": driver,
"account": account_info,
},
)
xml_str = ET.tostring(multistatus, encoding='unicode', xml_declaration=True)
if self.cache_manager and await self._is_webdav_metadata_cache_enabled():
await self.cache_manager.set_webdav_metadata_cache(account_id, cache_key_path, xml_str)
return Response(
content=xml_str,
status_code=207,
headers={'Content-Type': 'application/xml; charset=utf-8'}
)
except Exception as e:
webdav_logger.error(f"PROPFIND处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_root_propfind(self, path: str, depth: str) -> Response:
try:
from database.db import get_db
db_conn = await get_db()
if not db_conn:
webdav_logger.error("数据库连接不可用")
return Response(status_code=503, content="Database not available")
try:
async with db_conn.execute("SELECT id, name, driver_type, created_at FROM cloud_accounts WHERE is_active = 1") as cursor:
accounts = await cursor.fetchall()
except Exception as db_error:
webdav_logger.error(f"查询账号失败: {db_error}")
return Response(status_code=503, content="Database query failed")
if not accounts:
return Response(status_code=404, content="No accounts available")
root_info = FileItem(
id="root",
name="",
path="/",
size=0,
is_dir=True,
modified=datetime.now(timezone.utc),
created=datetime.now(timezone.utc)
)
webdav_logger.debug(f"PROPFIND根路径处理: depth={depth}, 账号数量={len(accounts)}")
if depth == '0':
webdav_logger.debug("使用Depth=0响应")
xml_response = generate_propfind_response(root_info, path)
else:
webdav_logger.debug("使用Depth=1响应,包含账号列表")
xml_response = self._generate_root_propfind_response(root_info, path, accounts)
return Response(
content=xml_response,
media_type="application/xml",
headers={
'Content-Type': 'application/xml; charset=utf-8',
'DAV': '1, 2'
}
)
except Exception as e:
webdav_logger.error(f"根路径PROPFIND处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
def _build_webdav_xml_element(self, parent, path: str, file_info: Optional[FileItem], is_collection: bool = False):
import xml.etree.ElementTree as ET
from urllib.parse import quote
from datetime import datetime
import mimetypes
response = ET.SubElement(parent, '{DAV:}response')
href = ET.SubElement(response, '{DAV:}href')
href.text = '/dav' + quote(path.encode('utf-8'))
if is_collection and not href.text.endswith('/'):
href.text += '/'
propstat = ET.SubElement(response, '{DAV:}propstat')
prop = ET.SubElement(propstat, '{DAV:}prop')
resourcetype = ET.SubElement(prop, '{DAV:}resourcetype')
if is_collection:
ET.SubElement(resourcetype, '{DAV:}collection')
folder_size = int(file_info.size or 0) if file_info else 0
ET.SubElement(prop, '{DAV:}getcontentlength').text = str(folder_size if folder_size > 0 else 0)
elif file_info:
ET.SubElement(prop, '{DAV:}getcontentlength').text = str(file_info.size)
if file_info:
displayname = ET.SubElement(prop, '{DAV:}displayname')
displayname.text = file_info.name
getlastmodified = ET.SubElement(prop, '{DAV:}getlastmodified')
if file_info and file_info.modified:
getlastmodified.text = file_info.modified.strftime(WEBDAV_TIME_FORMAT)
else:
getlastmodified.text = datetime.now(timezone.utc).strftime(WEBDAV_TIME_FORMAT)
creationdate = ET.SubElement(prop, '{DAV:}creationdate')
if file_info and file_info.created:
creationdate.text = file_info.created.strftime(WEBDAV_ISO_TIME_FORMAT)
else:
creationdate.text = datetime.now(timezone.utc).strftime(WEBDAV_ISO_TIME_FORMAT)
getcontenttype = ET.SubElement(prop, '{DAV:}getcontenttype')
if is_collection:
getcontenttype.text = 'httpd/unix-directory'
elif file_info:
mime_type, _ = mimetypes.guess_type(file_info.name)
getcontenttype.text = mime_type or 'application/octet-stream'
status = ET.SubElement(propstat, '{DAV:}status')
status.text = 'HTTP/1.1 200 OK'
def _generate_root_propfind_response(self, root_info: FileItem, path: str, accounts) -> str:
import xml.etree.ElementTree as ET
root = ET.Element('{DAV:}multistatus')
self._build_webdav_xml_element(root, path, root_info, is_collection=True)
for account_id, account_name, driver_type, created_at in accounts:
account_path = f'{path.rstrip("/")}/{account_name}/'
account_info = FileItem(
id=str(account_id),
name=account_name,
path=account_path,
size=0,
is_dir=True,
modified=_parse_db_timestamp(created_at),
created=_parse_db_timestamp(created_at)
)
self._build_webdav_xml_element(root, account_path, account_info, is_collection=True)
return ET.tostring(root, encoding='unicode', xml_declaration=True)
def _generate_directory_propfind_response(self, dir_info: FileItem, path: str, files) -> str:
import xml.etree.ElementTree as ET
root = ET.Element('{DAV:}multistatus')
self._build_webdav_xml_element(root, path, dir_info, is_collection=True)
for file in files:
file_path = f'{path.rstrip("/")}/{file.name}'
if file.is_dir:
file_path += '/'
self._build_webdav_xml_element(root, file_path, file, is_collection=file.is_dir)
return ET.tostring(root, encoding='unicode', xml_declaration=True)
async def _get_resource(self, path: str) -> Optional[Dict]:
try:
account_name, file_path = parse_webdav_path(path)
if not account_name:
from database.db import get_db
db_conn = await get_db()
if not db_conn:
webdav_logger.error("数据库连接不可用")
return None
try:
async with db_conn.execute("SELECT id, name, driver_type, created_at FROM cloud_accounts WHERE is_active = 1") as cursor:
accounts = await cursor.fetchall()
except Exception as db_error:
webdav_logger.error(f"查询账号失败: {db_error}")
return None
result = {
'is_collection': True,
'file_info': None,
'driver': None,
'account': None,
'accounts': accounts
}
return result
driver = await self._get_driver_by_name(account_name)
if not driver:
return None
from database.db import get_db
db_conn = await get_db()
if not db_conn:
webdav_logger.error("数据库连接不可用")
return None
try:
async with db_conn.execute(
"SELECT id, name, driver_type, config, created_at FROM cloud_accounts WHERE name = ? AND is_active = 1",
(account_name,)
) as cursor:
account = await cursor.fetchone()
except Exception as db_error:
webdav_logger.error(f"查询账号失败: {db_error}")
return None
if not account:
return None
account_id, name, driver_type, config, created_at = account
if file_path == "/" or file_path == "":
account_time = _parse_db_timestamp(created_at)
result = {
'is_collection': True,
'file_info': FileItem(
id="0",
name=account_name,
path="/",
size=0,
is_dir=True,
modified=account_time,
created=account_time
),
'driver': driver,
'account': {
'id': account_id,
'name': name,
'driver_type': driver_type,
'config': config
},
}
return result
file_info = await self._resolve_path_to_file(driver, file_path)
if file_info:
result = {
'is_collection': file_info.is_dir,
'file_info': file_info,
'driver': driver,
'account': {
'id': account_id,
'name': name,
'driver_type': driver_type,
'config': config
},
}
return result
return None
except Exception as e:
webdav_logger.error(f"获取资源信息失败 {path}: {e}")
return None
async def _list_children(self, path: str) -> List[Tuple[str, Dict]]:
resource = await self._get_resource(path)
if not resource:
webdav_logger.error(f"无法获取资源信息: {path}")
return []
if not resource.get('is_collection', False):
return []
children = []
if not path.strip('/'):
# 根目录返回所有账号作为子资源
accounts = resource.get('accounts', [])
for account_id, account_name, driver_type, created_at in accounts:
children.append((account_name, {
'is_collection': True,
'file_info': FileItem(
id="0",
name=account_name,
path="/",
size=0,
is_dir=True,
modified=_parse_db_timestamp(created_at),
created=_parse_db_timestamp(created_at)
),
'driver': None,
'account': {
'id': account_id,
'name': account_name,
'driver_type': driver_type
},
}))
else:
driver = resource['driver']
file_id = resource['file_info'].id if resource['file_info'] else '0'
files = await driver.list_files(file_id)
for file_item in files:
children.append((file_item.name, {
'is_collection': file_item.is_dir,
'file_info': file_item,
'driver': driver,
'account': resource['account'],
}))
return children
async def _generate_directory_listing(self, path: str, resource: Dict) -> str:
try:
if not path.strip('/'):
accounts = resource.get('accounts', [])
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>LitePan WebDAV - 根目录</title>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.header {{ background: #f0f0f0; padding: 10px; margin-bottom: 20px; }}
.account-list {{ list-style: none; padding: 0; }}
.account-item {{
padding: 10px;
margin: 5px 0;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
}}
.account-link {{
text-decoration: none;
color: #333;
font-weight: bold;
font-size: 16px;
}}
.account-link:hover {{ color: #007bff; }}
</style>
</head>
<body>
<div class="header">
<h1>LitePan WebDAV 根目录</h1>
<p>可用的网盘账号:</p>
</div>
<ul class="account-list">
"""
for account_id, account_name, driver_type, created_at in accounts:
html += f"""
<li class="account-item">
<a href="/dav/{account_name}/" class="account-link">📁 {account_name}</a>
</li>
"""
html += """
</ul>
</body>
</html>
"""
return html
else:
driver = resource['driver']
file_id = resource['file_info'].id if resource['file_info'] else '0'
files = await driver.list_files(file_id)
html = f"""
<!DOCTYPE html>
<html>
<head>
<title>LitePan WebDAV - {html_escape(path)}</title>
<meta charset="utf-8">
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.header {{ background: #f0f0f0; padding: 10px; margin-bottom: 20px; }}
.file-list {{ list-style: none; padding: 0; }}
.file-item {{
padding: 10px;
margin: 5px 0;
background: #fff;
border: 1px solid #ddd;
border-radius: 5px;
display: flex;
justify-content: space-between;
align-items: center;
}}
.file-link {{
text-decoration: none;
color: #333;
font-weight: bold;
}}
.file-link:hover {{ color: #007bff; }}
.file-size {{ color: #666; font-size: 12px; }}
.folder-icon {{ color: #ffc107; }}
.file-icon {{ color: #6c757d; }}
</style>
</head>
<body>
<div class="header">
<h1>📁 {html_escape(path)}</h1>
<p>文件列表:</p>
</div>
<ul class="file-list">
"""
for file_item in files:
safe_name = html_escape(file_item.name)
quoted_name = quote(file_item.name)
if file_item.is_dir:
icon = "📁"
link = f"/dav{path.rstrip('/')}/{quoted_name}/"
size = ""
else:
icon = "📄"
link = f"/dav{path.rstrip('/')}/{quoted_name}"
size = f" ({file_item.size} bytes)" if file_item.size else ""
html += f"""
<li class="file-item">
<a href="{link}" class="file-link">{icon} {safe_name}</a>
<span class="file-size">{size}</span>
</li>
"""
html += """
</ul>
</body>
</html>
"""
return html
except Exception as e:
webdav_logger.error(f"生成目录列表失败: {e}")
return f"<html><body><h1>错误</h1><p>生成目录列表失败: {html_escape(str(e))}</p></body></html>"
async def _add_response_element(self, parent: ET.Element, path: str, resource: Dict):
self._build_webdav_xml_element(
parent,
path,
resource.get('file_info'),
is_collection=resource.get('is_collection', False)
)
async def _get_driver_by_name(self, account_name: str):
context = await self._get_account_context_by_name(account_name)
if not context:
return None
return context['driver']
async def _get_account_context_by_name(self, account_name: str) -> Optional[Dict[str, Any]]:
try:
from database.db import get_db
db_conn = await get_db()
if not db_conn:
webdav_logger.error("数据库连接不可用")
return None
async with db_conn.execute(
"SELECT id, name, driver_type, config, created_at FROM cloud_accounts WHERE name = ? AND is_active = 1",
(account_name,)
) as cursor:
account = await cursor.fetchone()
if not account:
return None
account_id, name, driver_type, config, created_at = account
from core.driver_service import get_account_driver
driver = await get_account_driver(account_id, db_conn)
return {
'account_id': str(account_id),
'name': name,
'driver_type': driver_type,
'config': config,
'driver': driver,
'created_at': created_at,
}
except Exception as e:
webdav_logger.error(f"根据账号名称获取驱动失败: {e}")
return None
async def _handle_root_listing(self, path: str) -> Response:
try:
from database.db import get_db
db_conn = await get_db()
if not db_conn:
webdav_logger.error("数据库连接不可用")
return Response(status_code=503, content="Database not available")
# 获取所有启用的账号
try:
async with db_conn.execute("SELECT id, name, driver_type, created_at FROM cloud_accounts WHERE is_active = 1") as cursor:
accounts = await cursor.fetchall()
except Exception as db_error:
webdav_logger.error(f"查询账号失败: {db_error}")
return Response(status_code=503, content="Database query failed")
if not accounts:
return Response(status_code=404, content="No accounts available")
# 创建根目录的FileItem
root_info = FileItem(
id="root",
name="",
path="/",
size=0,
is_dir=True,
modified=datetime.now(timezone.utc),
created=datetime.now(timezone.utc)
)
# 生成PROPFIND响应,包含所有账号
xml_response = self._generate_root_propfind_response(root_info, path, accounts)
return Response(
content=xml_response,
media_type="application/xml",
headers={
'Content-Type': 'application/xml; charset=utf-8',
'DAV': '1, 2'
}
)
except Exception as e:
webdav_logger.error(f"根路径处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_root_get(self, path: str) -> Response:
try:
from database.db import get_db
db_conn = await get_db()
if not db_conn:
webdav_logger.error("数据库连接不可用")
return Response(status_code=503, content="Database not available")
# 获取所有启用的账号
try:
async with db_conn.execute("SELECT id, name, driver_type, created_at FROM cloud_accounts WHERE is_active = 1") as cursor:
accounts = await cursor.fetchall()
except Exception as db_error:
webdav_logger.error(f"查询账号失败: {db_error}")
return Response(status_code=503, content="Database query failed")
if not accounts:
return Response(status_code=404, content="No accounts available")
# 生成HTML目录列表,显示所有账号作为文件夹
html_content = f"""
<!DOCTYPE html>
<html>
<head>
<title>Directory listing for /</title>
<style>
body {{ font-family: Arial, sans-serif; margin: 20px; }}
.file-list {{ border-collapse: collapse; width: 100%; }}
.file-list th, .file-list td {{ border: 1px solid #ddd; padding: 8px; text-align: left; }}
.file-list th {{ background-color: #f2f2f2; }}
.directory {{ color: #0066cc; }}
.file {{ color: #333; }}
.size {{ text-align: right; }}
</style>
</head>
<body>
<h1>Directory listing for /</h1>
<table class="file-list">
<tr>
<th>Name</th>
<th>Size</th>
<th>Modified</th>
</tr>
"""
# 添加所有账号作为目录
for account_id, account_name, driver_type, created_at in accounts:
html_content += f"""
<tr>
<td><a href="/dav/{account_name}/" class="directory">📁 {account_name}</a></td>
<td class="size">-</td>
<td>-</td>
</tr>
"""
html_content += """
</table>
</body>
</html>
"""
return Response(
content=html_content,
media_type="text/html",
headers={
'Content-Type': 'text/html; charset=utf-8'
}
)
except Exception as e:
webdav_logger.error(f"根路径GET处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_get(self, request: Request, path: str) -> Response:
try:
resource = await self._get_resource(path)
if not resource:
return Response(status_code=404, content="Resource not found")
if resource['is_collection']:
html_content = await self._generate_directory_listing(path, resource)
return Response(
content=html_content,
media_type="text/html",
headers={
'Cache-Control': 'public, max-age=1800',
}
)
else:
file_info = resource['file_info']
account = resource.get('account', {})
account_id = account['id']
return await self._handle_file_download(resource['driver'], file_info, request)
except Exception as e:
webdav_logger.error(f"GET处理错误: {e}", exc_info=True)
return Response(status_code=500, content=f"Internal Server Error: {str(e)}")
async def _handle_directory_listing(self, driver, parent_id: str, webdav_path: str) -> Response:
try:
webdav_logger.debug(f"处理目录列表: parent_id={parent_id}, webdav_path={webdav_path}")
files = await driver.list_files(parent_id)
webdav_logger.debug(f"获取到 {len(files)} 个文件/文件夹")
html_content = generate_directory_html(files, webdav_path)
return Response(
content=html_content,
media_type="text/html",
headers={
'Content-Type': 'text/html; charset=utf-8'
}
)
except Exception as e:
webdav_logger.error(f"目录列表生成失败: {e}", exc_info=True)
return Response(status_code=500, content=f"Directory listing failed: {str(e)}")
async def _handle_file_download(self, driver, file_info: FileItem, request: Request) -> Response:
try:
etag = self._build_etag(file_info)
last_modified = file_info.modified
range_header = request.headers.get('Range')
if not range_header:
if self._match_if_none_match(request, etag) or self._match_if_modified_since(request, last_modified):
return self._build_not_modified_response(etag, last_modified)
client_user_agent = request.headers.get('User-Agent', '')
download = await resolve_download(
driver,
file_info.id,
client_user_agent,
file_info=file_info,
)
download_url = download.download_url
if not download_url:
return Response(status_code=500, content="Failed to get download URL")
download_mode = get_effective_download_mode(driver, download)
if range_header:
if_range = (request.headers.get("If-Range") or "").strip()
if if_range:
if if_range.startswith('"') and if_range != etag:
range_header = None
elif not if_range.startswith('"'):
try:
if_range_dt = parsedate_to_datetime(if_range)
if if_range_dt.tzinfo:
if_range_dt = if_range_dt.astimezone(tz=None).replace(tzinfo=None)
if last_modified and last_modified.replace(microsecond=0) > if_range_dt.replace(microsecond=0):
range_header = None
except Exception:
range_header = None
if download_mode == 'redirect':
if range_header:
return await self._handle_redirect_range_request(download_url, file_info, range_header)
else:
return Response(
status_code=302,
headers={
'Location': download_url,
'Cache-Control': 'public, max-age=3600',
'ETag': etag,
'Last-Modified': self._format_http_datetime(last_modified),
}
)
else:
proxy_file_info, proxy_download_url, _ = build_proxy_file_info_from_download(file_info.id, download)
if not proxy_file_info:
proxy_file_info = file_info
return await serve_range_proxy(
driver=driver,
file_id=file_info.id,
file_info=proxy_file_info,
request=request,
initial_url=proxy_download_url or download_url,
upstream_headers_override=download.headers,
)
except Exception as e:
webdav_logger.error(f"文件下载失败: {e}", exc_info=True)
return Response(status_code=500, content="Download failed")
async def _handle_head(self, request: Request, path: str) -> Response:
try:
account_name, file_path = parse_webdav_path(path)
if not account_name:
headers = {
'Content-Type': 'text/html; charset=utf-8',
'DAV': '1, 2'
}
return Response(status_code=200, headers=headers)
context = await self._get_account_context_by_name(account_name)
if not context:
return Response(status_code=404, content="Account not found")
driver = context["driver"]
account_id = context["account_id"]
if file_path in ("", "/"):
file_info = FileItem(
id="0",
name=account_name,
path="/",
size=0,
is_dir=True,
modified=datetime.now(timezone.utc),
created=datetime.now(timezone.utc),
)
else:
file_info = await self._resolve_path_to_file(driver, file_path, account_id=account_id, log_missing=False)
if not file_info:
file_info = await driver.file_info(file_path)
if not file_info:
return Response(status_code=404, content="File not found")
etag = self._build_etag(file_info)
last_modified = file_info.modified
if self._match_if_none_match(request, etag) or self._match_if_modified_since(request, last_modified):
return self._build_not_modified_response(etag, last_modified)
headers = {
'Content-Length': str(file_info.size),
'Last-Modified': self._format_http_datetime(last_modified),
'ETag': etag,
'Accept-Ranges': 'bytes'
}
if file_info.is_dir:
headers['Content-Type'] = 'httpd/unix-directory'
else:
headers['Content-Type'] = get_mime_type(file_info.name)
return Response(status_code=200, headers=headers)
except Exception as e:
webdav_logger.error(f"HEAD处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_redirect_range_request(self, url: str, file_info: FileItem, range_header: str) -> Response:
try:
start, end = self._parse_single_range(range_header, int(file_info.size or 0))
return Response(
status_code=302,
headers={
'Location': url,
'Accept-Ranges': 'bytes',
'Content-Range': f'bytes {start}-{end}/{file_info.size}',
}
)
except Exception as e:
webdav_logger.error(f"Range请求处理失败: {e}")
total = int(file_info.size or 0)
headers = {}
if total > 0:
headers["Content-Range"] = f"bytes */{total}"
return Response(status_code=416, content="Invalid range request", headers=headers)
async def _handle_put(self, request: Request, path: str) -> Response:
"""PUT:请求体先落临时文件再交给驱动上传,避免全部驻留内存。"""
temp_path = ""
try:
account_name, file_path = parse_webdav_path(path)
if not account_name:
return Response(status_code=403, content="Cannot upload to WebDAV root")
if file_path in ('', '/'):
return Response(status_code=403, content="Cannot upload to account root directory")
context = await self._get_account_context_by_name(account_name)
if not context:
return Response(status_code=404, content="Account not found")
driver = context['driver']
account_id = context['account_id']
if not hasattr(driver, 'upload_local_file'):
return Response(status_code=501, content="Upload is not supported by this driver")
normalized_path = file_path.strip('/')
path_parts = [part for part in normalized_path.split('/') if part]
if not path_parts:
return Response(status_code=400, content="Invalid file path")
file_name = path_parts[-1]
parent_path = "/".join(path_parts[:-1])
parent_id = "0"
if parent_path:
parent_info = await self._resolve_path_to_file(driver, parent_path, account_id=account_id)
if not parent_info:
return Response(status_code=409, content="Parent collection does not exist")
if not parent_info.is_dir:
return Response(status_code=409, content="Parent path is not a collection")
parent_id = str(parent_info.id)
existing = await self._resolve_path_to_file(driver, file_path, account_id=account_id, log_missing=False)
if existing and existing.is_dir:
return Response(status_code=405, content="Cannot overwrite a collection with PUT")
temp_path = await self._save_webdav_request_to_tempfile(request, file_name)
token = current_account_id.set(account_id)
try:
result = await driver.upload_local_file(
temp_path,
file_name,
parent_id,
conflict_policy="overwrite",
)
finally:
current_account_id.reset(token)
if not result or not result.success:
message = result.message if result else "Upload failed"
webdav_logger.warning(f"WebDAV上传失败: {path} - {message}")
return Response(status_code=409, content=message)
if self.cache_cleaner:
try:
await self.cache_cleaner.on_file_created(
account_id=account_id,
parent_id=parent_id,
file_name=file_name,
)
except Exception as e:
webdav_logger.warning(f"清除上传缓存失败: {e}")
return Response(status_code=204 if existing else 201)
except Exception as e:
webdav_logger.error(f"PUT处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
finally:
if temp_path and os.path.exists(temp_path):
try:
os.remove(temp_path)
except OSError:
pass
async def _handle_delete(self, request: Request, path: str) -> Response:
try:
account_name, file_path = parse_webdav_path(path)
if not account_name:
return Response(status_code=403, content="Cannot delete WebDAV root")
if file_path in ('', '/'):
return Response(status_code=403, content="Cannot delete account root directory")
context = await self._get_account_context_by_name(account_name)
if not context:
return Response(status_code=404, content="Account not found")
driver = context['driver']
account_id = context['account_id']
file_info = await self._resolve_path_to_file(driver, file_path, account_id=account_id)
if not file_info:
return Response(status_code=404, content="File not found")
parent_id = None
normalized_path = file_path.strip('/')
path_parts = [part for part in normalized_path.split('/') if part]
if len(path_parts) > 1:
parent_path = "/".join(path_parts[:-1])
parent_info = await self._resolve_path_to_file(driver, parent_path, account_id=account_id, log_missing=False)
if parent_info and parent_info.is_dir:
parent_id = str(parent_info.id)
if not parent_id:
parent_id = "0"
token = current_account_id.set(account_id)
try:
result = await driver.delete_file(file_info.id)
finally:
current_account_id.reset(token)
if not result or not result.success:
message = result.message if result else "Delete operation failed"
webdav_logger.warning(f"WebDAV删除失败: {path} - {message}")
return Response(status_code=409, content=message)
if self.cache_cleaner:
try:
await self.cache_cleaner.on_file_deleted(
account_id=account_id,
file_id=str(file_info.id),
parent_id=parent_id,
)
except Exception as e:
webdav_logger.warning(f"清除删除缓存失败: {e}")
return Response(status_code=204)
except Exception as e:
webdav_logger.error(f"DELETE处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_mkcol(self, request: Request, path: str) -> Response:
# rclone / 某些客户端会尝试创建以 . 开头的隐藏元数据目录,直接返回 201 避免报错
if path.startswith('/.') or '.ADSPOWER_GLOBAL' in path:
return Response(status_code=201, content="Directory created")
try:
account_name, file_path = parse_webdav_path(path)
if not account_name:
return Response(status_code=403, content="Cannot create directory at WebDAV root")
if file_path in ('', '/'):
return Response(status_code=403, content="Cannot create account root directory")
context = await self._get_account_context_by_name(account_name)
if not context:
return Response(status_code=404, content="Account not found")
driver = context['driver']
account_id = context['account_id']
normalized_path = file_path.strip('/')
path_parts = [part for part in normalized_path.split('/') if part]
if not path_parts:
return Response(status_code=400, content="Invalid directory path")
folder_name = path_parts[-1]
parent_path = "/".join(path_parts[:-1])
parent_id = "0"
existing = await self._resolve_path_to_file(driver, file_path, account_id=account_id)
if existing:
return Response(status_code=405, content="Resource already exists")
if parent_path:
parent_info = await self._resolve_path_to_file(driver, parent_path, account_id=account_id)
if not parent_info:
return Response(status_code=409, content="Parent collection does not exist")
if not parent_info.is_dir:
return Response(status_code=409, content="Parent path is not a collection")
parent_id = str(parent_info.id)
token = current_account_id.set(account_id)
try:
result = await driver.create_folder(parent_id, folder_name)
finally:
current_account_id.reset(token)
if not result or not result.success:
message = result.message if result else "Create collection failed"
webdav_logger.warning(f"WebDAV创建目录失败: {path} - {message}")
return Response(status_code=409, content=message)
if self.cache_cleaner:
try:
await self.cache_cleaner.on_file_created(
account_id=account_id,
parent_id=parent_id,
file_name=folder_name,
)
except Exception as e:
webdav_logger.warning(f"清除创建目录缓存失败: {e}")
return Response(status_code=201, content="Collection created")
except Exception as e:
webdav_logger.error(f"MKCOL处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_move(self, request: Request, path: str) -> Response:
"""MOVE:只支持同账号内的重命名/移动,跨账号直接 501。"""
try:
source_account_name, source_file_path = parse_webdav_path(path)
if not source_account_name:
return Response(status_code=403, content="Cannot move WebDAV root")
if source_file_path in ('', '/'):
return Response(status_code=403, content="Cannot move account root directory")
destination = request.headers.get('Destination', '').strip()
if not destination:
return Response(status_code=400, content="Missing Destination header")
overwrite = request.headers.get('Overwrite', 'T').strip().upper() != 'F'
destination_path = unquote(destination)
if '://' in destination_path:
destination_path = destination_path.split('://', 1)[1]
destination_path = destination_path[destination_path.find('/'):] if '/' in destination_path else '/'
target_account_name, target_file_path = parse_webdav_path(destination_path)
if not target_account_name:
return Response(status_code=400, content="Invalid Destination path")
if target_account_name != source_account_name:
return Response(status_code=501, content="Cross-account MOVE is not supported")
if target_file_path in ('', '/'):
return Response(status_code=403, content="Cannot move to account root directory")
context = await self._get_account_context_by_name(source_account_name)
if not context:
return Response(status_code=404, content="Account not found")
driver = context['driver']
account_id = context['account_id']
source_info = await self._resolve_path_to_file(driver, source_file_path, account_id=account_id)
if not source_info:
return Response(status_code=404, content="Source not found")
target_parts = [part for part in target_file_path.strip('/').split('/') if part]
if not target_parts:
return Response(status_code=400, content="Invalid Destination path")
target_name = target_parts[-1]
target_parent_path = "/".join(target_parts[:-1])
target_parent_id = "0"
if target_parent_path:
target_parent_info = await self._resolve_path_to_file(driver, target_parent_path, account_id=account_id)
if not target_parent_info:
return Response(status_code=409, content="Destination parent does not exist")
if not target_parent_info.is_dir:
return Response(status_code=409, content="Destination parent is not a collection")
target_parent_id = str(target_parent_info.id)
source_parent_path = "/".join([part for part in source_file_path.strip('/').split('/')[:-1] if part])
source_parent_id = "0"
if source_parent_path:
source_parent_info = await self._resolve_path_to_file(driver, source_parent_path, account_id=account_id)
if not source_parent_info or not source_parent_info.is_dir:
return Response(status_code=409, content="Source parent collection does not exist")
source_parent_id = str(source_parent_info.id)
existing_target = await self._resolve_path_to_file(driver, target_file_path, account_id=account_id, log_missing=False)
if existing_target:
if str(existing_target.id) == str(source_info.id):
return Response(status_code=204)
if not overwrite:
return Response(status_code=412, content="Destination already exists")
delete_token = current_account_id.set(account_id)
try:
delete_result = await driver.delete_file(existing_target.id)
finally:
current_account_id.reset(delete_token)
if not delete_result or not delete_result.success:
message = delete_result.message if delete_result else "Failed to overwrite destination"
return Response(status_code=409, content=message)
token = current_account_id.set(account_id)
try:
if source_parent_id == target_parent_id:
if source_info.name == target_name:
return Response(status_code=204)
result = await driver.rename_file(source_info.id, target_name)
else:
move_result = await driver.move_file([source_info.id], target_parent_id)
if not move_result or not move_result.success:
message = move_result.message if move_result else "Move operation failed"
webdav_logger.warning(f"WebDAV移动失败: {path} -> {destination_path} - {message}")
return Response(status_code=409, content=message)
if source_info.name != target_name:
result = await driver.rename_file(source_info.id, target_name)
else:
result = move_result
finally:
current_account_id.reset(token)
if not result or not result.success:
message = result.message if result else "Move operation failed"
webdav_logger.warning(f"WebDAV MOVE失败: {path} -> {destination_path} - {message}")
return Response(status_code=409, content=message)
await self._cleanup_webdav_move_cache(
account_id=account_id,
file_id=str(source_info.id),
source_parent_id=source_parent_id,
target_parent_id=target_parent_id,
source_name=source_info.name,
target_name=target_name,
)
return Response(status_code=201)
except Exception as e:
webdav_logger.error(f"MOVE处理错误: {e}", exc_info=True)
return Response(status_code=500, content="Internal Server Error")
async def _handle_copy(self, request: Request, path: str) -> Response:
# 大多数云盘驱动没有复制能力,统一返回 501
return Response(status_code=501, content="Copy operation not supported")
async def _cleanup_webdav_move_cache(
self,
account_id: str,
file_id: str,
source_parent_id: str,
target_parent_id: str,
source_name: str,
target_name: str,
) -> None:
if not self.cache_cleaner:
return
if source_parent_id == target_parent_id:
await self.cache_cleaner.on_file_renamed(
account_id=account_id,
parent_id=source_parent_id,
file_id=file_id,
old_name=source_name,
new_name=target_name,
)
return
await self.cache_cleaner.on_file_moved(
account_id=account_id,
file_id=file_id,
old_parent_id=source_parent_id,
new_parent_id=target_parent_id,
)
if source_name != target_name:
await self.cache_cleaner.on_file_renamed(
account_id=account_id,
parent_id=target_parent_id,
file_id=file_id,
old_name=source_name,
new_name=target_name,
)
async def _save_webdav_request_to_tempfile(self, request: Request, file_name: str) -> str:
upload_dir = os.path.join("data", "upload_tasks")
os.makedirs(upload_dir, exist_ok=True)
safe_name = os.path.basename(file_name) or "upload.bin"
temp_name = f"webdav_{uuid.uuid4().hex}_{safe_name}"
temp_path = os.path.join(upload_dir, temp_name)
with open(temp_path, "wb") as handle:
async for chunk in request.stream():
if chunk:
handle.write(chunk)
return temp_path
async def _clear_account_path_mapping_cache(self, account_id: str) -> None:
if not account_id or not self.cache_manager:
return
from cache.cache_keys import CacheKeyGenerator
await self.cache_manager.clear_by_prefix(CacheKeyGenerator.path_mapping_prefix(account_id))
async def _resolve_path_to_file(
self,
driver,
file_path: str,
account_id: Optional[str] = None,
log_missing: bool = True,
allow_path_cache_retry: bool = True,
) -> Optional[FileItem]:
"""路径 → FileItem;命中缓存但被判定陈旧(解析不到)时清缓存重试一次。"""
try:
if file_path == "/" or file_path == "":
return None
normalized_file_path = "/" + file_path.strip("/")
normalized_cache_path = self._normalize_cache_path(normalized_file_path)
path_cache_enabled = False
if account_id and self.cache_manager:
path_cache_enabled = await self._is_webdav_path_cache_enabled()
if path_cache_enabled:
cached_item = await self.cache_manager.get_path_mapping_cache(account_id, normalized_cache_path)
if isinstance(cached_item, FileItem):
return cached_item
async def retry_without_path_cache(reason: str) -> Optional[FileItem]:
if not (allow_path_cache_retry and path_cache_enabled and account_id and self.cache_manager):
return None
try:
webdav_logger.debug(f"WebDAV路径解析命中旧缓存,已清理路径缓存并重试: {reason} -> {normalized_cache_path}")
await self._clear_account_path_mapping_cache(str(account_id))
except Exception as clear_error:
webdav_logger.warning(f"清理路径映射缓存失败,无法执行自动重试: {clear_error}")
return None
return await self._resolve_path_to_file(
driver,
file_path,
account_id=account_id,
log_missing=log_missing,
allow_path_cache_retry=False,
)
path_parts = [p for p in normalized_file_path.split('/') if p]
if not path_parts:
return None
current_id = "0"
resolved_parts: List[str] = []
for part in path_parts:
files = await driver.list_files(current_id)
found = None
for file_item in files:
if file_item.name == part:
found = file_item
break
if not found:
if log_missing:
webdav_logger.debug(f"路径解析失败: 在 {current_id} 中未找到 {part}")
retried_item = await retry_without_path_cache(f"目录 {current_id} 中未找到 {part}")
if retried_item is not None:
return retried_item
return None
current_id = found.id
resolved_parts.append(part)
if path_cache_enabled and account_id and self.cache_manager:
try:
resolved_path = "/" + "/".join(resolved_parts)
await self.cache_manager.set_path_mapping_cache(account_id, resolved_path, found)
except Exception as e:
webdav_logger.warning(f"设置路径映射缓存失败: {e}")
if part == path_parts[-1]:
if path_cache_enabled and account_id and self.cache_manager:
try:
await self.cache_manager.set_path_mapping_cache(account_id, normalized_cache_path, found)
except Exception as e:
webdav_logger.warning(f"设置最终路径映射缓存失败: {e}")
return found
if not found.is_dir:
if log_missing:
webdav_logger.debug(f"路径解析失败: {part} 不是文件夹,但还有更多路径部分")
retried_item = await retry_without_path_cache(f"{part} 不是文件夹")
if retried_item is not None:
return retried_item
return None
return None
except Exception as e:
webdav_logger.error(f"路径解析失败: {e}")
retried_item = await retry_without_path_cache(f"路径解析异常: {e}")
if retried_item is not None:
return retried_item
return None
webdav_server = None
async def get_webdav_server() -> Optional[FastAPIWebDAVServer]:
"""按需拿 WebDAV 服务器实例;开关关闭时会顺带把缓存一起清掉。"""
global webdav_server
try:
webdav_enabled = config_manager.get('webdav_enabled')
if not webdav_enabled:
if webdav_server is not None:
webdav_logger.info("WebDAV服务已禁用,清除服务器实例和缓存")
webdav_server = None
await clear_webdav_cache()
return None
except Exception as e:
webdav_logger.error(f"检查WebDAV配置失败: {e}")
return None
if webdav_server is None:
webdav_server = FastAPIWebDAVServer()
return webdav_server
async def reset_webdav_server():
global webdav_server
webdav_server = None
webdav_logger.info("WebDAV服务器实例已重置")
async def _clear_all_webdav_related_cache() -> int:
cache_manager = get_global_cache()
if not cache_manager:
return 0
from cache.cache_types import CacheConstants
cleared_count = 0
cleared_count += await cache_manager.clear_by_prefix(
CacheConstants.KEY_PREFIX_PATH + CacheConstants.KEY_SEPARATOR
)
cleared_count += await cache_manager.clear_by_prefix(
CacheConstants.KEY_PREFIX_WEBDAV + CacheConstants.KEY_SEPARATOR
)
return int(cleared_count)
async def clear_webdav_cache():
try:
cleared_count = await _clear_all_webdav_related_cache()
webdav_logger.info(f"WebDAV缓存已清除,累计清理 {cleared_count} 项")
except Exception as e:
webdav_logger.error(f"清除WebDAV缓存失败: {e}")