feat: implement WebDAV protocol support with WsgiDAV + account management API
All checks were successful
Test / test (push) Successful in 2m14s

Add complete WebDAV support: management REST API (CRUD accounts at /api/v1/webdav/accounts)
and DAV protocol endpoint (/dav) using WsgiDAV + a2wsgi bridge for client access via
HTTP Basic Auth. Includes Redis+TTLCache auth caching and integration tests (24 cases).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 15:19:29 +08:00
parent 19837b4817
commit 40b6a31c98
13 changed files with 1852 additions and 94 deletions

View File

@@ -5,6 +5,8 @@ from fastapi import FastAPI, Request
from loguru import logger as l from loguru import logger as l
from routers import router from routers import router
from routers.dav import dav_app
from routers.dav.provider import EventLoopRef
from service.redis import RedisManager from service.redis import RedisManager
from sqlmodels.database_connection import DatabaseManager from sqlmodels.database_connection import DatabaseManager
from sqlmodels.migration import migration from sqlmodels.migration import migration
@@ -40,6 +42,9 @@ async def _init_db() -> None:
"""初始化数据库连接引擎""" """初始化数据库连接引擎"""
await DatabaseManager.init(appmeta.database_url, debug=appmeta.debug) await DatabaseManager.init(appmeta.database_url, debug=appmeta.debug)
# 捕获事件循环引用(供 WSGI 线程桥接使用)
lifespan.add_startup(EventLoopRef.capture)
# 添加初始化数据库启动项 # 添加初始化数据库启动项
lifespan.add_startup(_init_db) lifespan.add_startup(_init_db)
lifespan.add_startup(migration) lifespan.add_startup(migration)
@@ -88,6 +93,9 @@ async def handle_unexpected_exceptions(
# 挂载路由 # 挂载路由
app.include_router(router) app.include_router(router)
# 挂载 WebDAV 协议端点(优先于 SPA catch-all
app.mount("/dav", dav_app)
# 挂载前端静态文件(仅当 statics/ 目录存在时,即 Docker 部署环境) # 挂载前端静态文件(仅当 statics/ 目录存在时,即 Docker 部署环境)
if STATICS_DIR.is_dir(): if STATICS_DIR.is_dir():
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles

View File

@@ -33,6 +33,8 @@ dependencies = [
"uvicorn>=0.38.0", "uvicorn>=0.38.0",
"webauthn>=2.7.0", "webauthn>=2.7.0",
"whatthepatch>=1.0.6", "whatthepatch>=1.0.6",
"wsgidav>=4.3.0",
"a2wsgi>=1.10.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]

View File

@@ -1,110 +1,207 @@
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from loguru import logger as l
from middleware.auth import auth_required from middleware.auth import auth_required
from sqlmodels import ResponseBase from middleware.dependencies import SessionDep
from sqlmodels import (
Object,
User,
WebDAV,
WebDAVAccountResponse,
WebDAVCreateRequest,
WebDAVUpdateRequest,
)
from service.redis.webdav_auth_cache import WebDAVAuthCache
from utils import http_exceptions from utils import http_exceptions
from utils.password.pwd import Password
# WebDAV 管理路由
webdav_router = APIRouter( webdav_router = APIRouter(
prefix='/webdav', prefix='/webdav',
tags=["webdav"], tags=["webdav"],
) )
def _check_webdav_enabled(user: User) -> None:
"""检查用户组是否启用了 WebDAV 功能"""
if not user.group.web_dav_enabled:
http_exceptions.raise_forbidden("WebDAV 功能未启用")
def _to_response(account: WebDAV) -> WebDAVAccountResponse:
"""将 WebDAV 数据库模型转换为响应 DTO"""
return WebDAVAccountResponse(
id=account.id,
name=account.name,
root=account.root,
readonly=account.readonly,
use_proxy=account.use_proxy,
created_at=str(account.created_at),
updated_at=str(account.updated_at),
)
@webdav_router.get( @webdav_router.get(
path='/accounts', path='/accounts',
summary='获取账号信息', summary='获取账号列表',
description='Get account information for WebDAV.',
dependencies=[Depends(auth_required)],
) )
def router_webdav_accounts() -> ResponseBase: async def list_accounts(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
) -> list[WebDAVAccountResponse]:
""" """
Get account information for WebDAV. 列出当前用户所有 WebDAV 账户
Returns: 认证JWT Bearer Token
ResponseBase: A model containing the response data for the account information.
""" """
http_exceptions.raise_not_implemented() _check_webdav_enabled(user)
user_id: UUID = user.id
accounts: list[WebDAV] = await WebDAV.get(
session,
WebDAV.user_id == user_id,
fetch_mode="all",
)
return [_to_response(a) for a in accounts]
@webdav_router.post( @webdav_router.post(
path='/accounts', path='/accounts',
summary='建账号', summary='建账号',
description='Create a new WebDAV account.', status_code=201,
dependencies=[Depends(auth_required)],
) )
def router_webdav_create_account() -> ResponseBase: async def create_account(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
request: WebDAVCreateRequest,
) -> WebDAVAccountResponse:
""" """
Create a new WebDAV account. 创建 WebDAV 账户
Returns:
ResponseBase: A model containing the response data for the created account.
"""
http_exceptions.raise_not_implemented()
@webdav_router.delete( 认证JWT Bearer Token
path='/accounts/{id}',
summary='删除账号',
description='Delete a WebDAV account by its ID.',
dependencies=[Depends(auth_required)],
)
def router_webdav_delete_account(id: str) -> ResponseBase:
"""
Delete a WebDAV account by its ID.
Args:
id (str): The ID of the account to be deleted.
Returns:
ResponseBase: A model containing the response data for the deletion operation.
"""
http_exceptions.raise_not_implemented()
@webdav_router.post( 错误处理:
path='/mount', - 403: WebDAV 功能未启用
summary='新建目录挂载', - 400: 根目录路径不存在或不是目录
description='Create a new WebDAV mount point.', - 409: 账户名已存在
dependencies=[Depends(auth_required)],
)
def router_webdav_create_mount() -> ResponseBase:
""" """
Create a new WebDAV mount point. _check_webdav_enabled(user)
user_id: UUID = user.id
Returns:
ResponseBase: A model containing the response data for the created mount point. # 验证账户名唯一
""" existing = await WebDAV.get(
http_exceptions.raise_not_implemented() session,
(WebDAV.name == request.name) & (WebDAV.user_id == user_id),
)
if existing:
http_exceptions.raise_conflict("账户名已存在")
# 验证 root 路径存在且为目录
root_obj = await Object.get_by_path(session, user_id, request.root)
if not root_obj or not root_obj.is_folder:
http_exceptions.raise_bad_request("根目录路径不存在或不是目录")
# 创建账户
account = WebDAV(
name=request.name,
password=Password.hash(request.password),
root=request.root,
readonly=request.readonly,
use_proxy=request.use_proxy,
user_id=user_id,
)
account = await account.save(session)
l.info(f"用户 {user_id} 创建 WebDAV 账户: {account.name}")
return _to_response(account)
@webdav_router.delete(
path='/mount/{id}',
summary='删除目录挂载',
description='Delete a WebDAV mount point by its ID.',
dependencies=[Depends(auth_required)],
)
def router_webdav_delete_mount(id: str) -> ResponseBase:
"""
Delete a WebDAV mount point by its ID.
Args:
id (str): The ID of the mount point to be deleted.
Returns:
ResponseBase: A model containing the response data for the deletion operation.
"""
http_exceptions.raise_not_implemented()
@webdav_router.patch( @webdav_router.patch(
path='accounts/{id}', path='/accounts/{account_id}',
summary='更新账号信息', summary='更新账号',
description='Update WebDAV account information by ID.',
dependencies=[Depends(auth_required)],
) )
def router_webdav_update_account(id: str) -> ResponseBase: async def update_account(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
account_id: int,
request: WebDAVUpdateRequest,
) -> WebDAVAccountResponse:
""" """
Update WebDAV account information by ID. 更新 WebDAV 账户
Args: 认证JWT Bearer Token
id (str): The ID of the account to be updated.
错误处理:
Returns: - 403: WebDAV 功能未启用
ResponseBase: A model containing the response data for the updated account. - 404: 账户不存在
- 400: 根目录路径不存在或不是目录
""" """
http_exceptions.raise_not_implemented() _check_webdav_enabled(user)
user_id: UUID = user.id
account = await WebDAV.get(
session,
(WebDAV.id == account_id) & (WebDAV.user_id == user_id),
)
if not account:
http_exceptions.raise_not_found("WebDAV 账户不存在")
# 验证 root 路径
if request.root is not None:
root_obj = await Object.get_by_path(session, user_id, request.root)
if not root_obj or not root_obj.is_folder:
http_exceptions.raise_bad_request("根目录路径不存在或不是目录")
# 密码哈希后原地替换update() 会通过 model_dump(exclude_unset=True) 只取已设置字段
is_password_changed = request.password is not None
if is_password_changed:
request.password = Password.hash(request.password)
account = await account.update(session, request)
# 密码变更时清除认证缓存
if is_password_changed:
await WebDAVAuthCache.invalidate_account(user_id, account.name)
l.info(f"用户 {user_id} 更新 WebDAV 账户: {account.name}")
return _to_response(account)
@webdav_router.delete(
path='/accounts/{account_id}',
summary='删除账号',
status_code=204,
)
async def delete_account(
session: SessionDep,
user: Annotated[User, Depends(auth_required)],
account_id: int,
) -> None:
"""
删除 WebDAV 账户
认证JWT Bearer Token
错误处理:
- 403: WebDAV 功能未启用
- 404: 账户不存在
"""
_check_webdav_enabled(user)
user_id: UUID = user.id
account = await WebDAV.get(
session,
(WebDAV.id == account_id) & (WebDAV.user_id == user_id),
)
if not account:
http_exceptions.raise_not_found("WebDAV 账户不存在")
account_name = account.name
await WebDAV.delete(session, account)
# 清除认证缓存
await WebDAVAuthCache.invalidate_account(user_id, account_name)
l.info(f"用户 {user_id} 删除 WebDAV 账户: {account_name}")

View File

@@ -1 +0,0 @@
# WebDAV 操作路由

35
routers/dav/__init__.py Normal file
View File

@@ -0,0 +1,35 @@
"""
WebDAV 协议入口
使用 WsgiDAV + a2wsgi 提供 WebDAV 协议支持。
WsgiDAV 在 a2wsgi 的线程池中运行,不阻塞 FastAPI 事件循环。
"""
from a2wsgi import WSGIMiddleware
from wsgidav.wsgidav_app import WsgiDAVApp
from .domain_controller import DiskNextDomainController
from .provider import DiskNextDAVProvider
_wsgidav_config: dict[str, object] = {
"provider_mapping": {
"/": DiskNextDAVProvider(),
},
"http_authenticator": {
"domain_controller": DiskNextDomainController,
"accept_basic": True,
"accept_digest": False,
"default_to_digest": False,
},
"verbose": 1,
# 使用 WsgiDAV 内置的内存锁管理器
"lock_storage": True,
# 禁用 WsgiDAV 的目录浏览器(纯 DAV 协议)
"dir_browser": {
"enable": False,
},
}
_wsgidav_app = WsgiDAVApp(_wsgidav_config)
dav_app = WSGIMiddleware(_wsgidav_app, workers=10)
"""ASGI 应用,挂载到 /dav 路径"""

View File

@@ -0,0 +1,148 @@
"""
WebDAV 认证控制器
实现 WsgiDAV 的 BaseDomainController 接口,使用 HTTP Basic Auth
通过 DiskNext 的 WebDAV 账户模型进行认证。
用户名格式: {email}/{webdav_account_name}
"""
import asyncio
from uuid import UUID
from loguru import logger as l
from wsgidav.dc.base_dc import BaseDomainController
from routers.dav.provider import EventLoopRef, _get_session
from service.redis.webdav_auth_cache import WebDAVAuthCache
from sqlmodels.user import User, UserStatus
from sqlmodels.webdav import WebDAV
from utils.password.pwd import Password, PasswordStatus
async def _authenticate(
email: str,
account_name: str,
password: str,
) -> tuple[UUID, int] | None:
"""
异步认证 WebDAV 用户。
:param email: 用户邮箱
:param account_name: WebDAV 账户名
:param password: 明文密码
:return: (user_id, webdav_id) 或 None
"""
# 1. 查缓存
cached = await WebDAVAuthCache.get(email, account_name, password)
if cached is not None:
return cached
# 2. 缓存未命中,查库验证
async with _get_session() as session:
user = await User.get(session, User.email == email, load=User.group)
if not user:
return None
if user.status != UserStatus.ACTIVE:
return None
if not user.group.web_dav_enabled:
return None
account = await WebDAV.get(
session,
(WebDAV.name == account_name) & (WebDAV.user_id == user.id),
)
if not account:
return None
status = Password.verify(account.password, password)
if status == PasswordStatus.INVALID:
return None
user_id: UUID = user.id
webdav_id: int = account.id
# 3. 写入缓存
await WebDAVAuthCache.set(email, account_name, password, user_id, webdav_id)
return user_id, webdav_id
class DiskNextDomainController(BaseDomainController):
"""
DiskNext WebDAV 认证控制器
用户名格式: {email}/{webdav_account_name}
密码: WebDAV 账户密码(创建账户时设置)
"""
def __init__(self, wsgidav_app: object, config: dict[str, object]) -> None:
super().__init__(wsgidav_app, config)
def get_domain_realm(self, path_info: str, environ: dict[str, object]) -> str:
"""返回 realm 名称"""
return "DiskNext WebDAV"
def require_authentication(self, realm: str, environ: dict[str, object]) -> bool:
"""所有请求都需要认证"""
return True
def is_share_anonymous(self, path_info: str) -> bool:
"""不支持匿名访问"""
return False
def supports_http_digest_auth(self) -> bool:
"""不支持 Digest 认证(密码存的是 Argon2 哈希,无法反推)"""
return False
def basic_auth_user(
self,
realm: str,
user_name: str,
password: str,
environ: dict[str, object],
) -> bool:
"""
HTTP Basic Auth 认证。
用户名格式: {email}/{webdav_account_name}
在 WSGI 线程中通过 anyio.from_thread.run 调用异步认证逻辑。
"""
# 解析用户名
if "/" not in user_name:
l.debug(f"WebDAV 认证失败: 用户名格式无效 '{user_name}'")
return False
email, account_name = user_name.split("/", 1)
if not email or not account_name:
l.debug(f"WebDAV 认证失败: 用户名格式无效 '{user_name}'")
return False
# 在 WSGI 线程中调用异步认证
future = asyncio.run_coroutine_threadsafe(
_authenticate(email, account_name, password),
EventLoopRef.get(),
)
result = future.result()
if result is None:
l.debug(f"WebDAV 认证失败: {email}/{account_name}")
return False
user_id, webdav_id = result
# 将认证信息存入 environ供 Provider 使用
environ["disknext.user_id"] = user_id
environ["disknext.webdav_id"] = webdav_id
environ["disknext.email"] = email
environ["disknext.account_name"] = account_name
return True
def digest_auth_user(
self,
realm: str,
user_name: str,
environ: dict[str, object],
) -> bool:
"""不支持 Digest 认证"""
return False

594
routers/dav/provider.py Normal file
View File

@@ -0,0 +1,594 @@
"""
DiskNext WebDAV 存储 Provider
将 WsgiDAV 的文件操作映射到 DiskNext 的 Object 模型。
所有异步数据库/文件操作通过 asyncio.run_coroutine_threadsafe() 桥接。
"""
import asyncio
import io
import mimetypes
from pathlib import Path
from typing import ClassVar
from uuid import UUID
from loguru import logger as l
from wsgidav.dav_error import (
DAVError,
HTTP_FORBIDDEN,
HTTP_INSUFFICIENT_STORAGE,
HTTP_NOT_FOUND,
)
from wsgidav.dav_provider import DAVCollection, DAVNonCollection, DAVProvider
from service.storage import LocalStorageService, adjust_user_storage
from sqlmodels.database_connection import DatabaseManager
from sqlmodels.object import Object, ObjectType
from sqlmodels.physical_file import PhysicalFile
from sqlmodels.policy import Policy
from sqlmodels.user import User
from sqlmodels.webdav import WebDAV
class EventLoopRef:
"""持有主线程事件循环引用,供 WSGI 线程使用"""
_loop: ClassVar[asyncio.AbstractEventLoop | None] = None
@classmethod
async def capture(cls) -> None:
"""在 async 上下文中调用,捕获当前事件循环"""
cls._loop = asyncio.get_running_loop()
@classmethod
def get(cls) -> asyncio.AbstractEventLoop:
if cls._loop is None:
raise RuntimeError("事件循环尚未捕获,请先调用 EventLoopRef.capture()")
return cls._loop
def _run_async(coro): # type: ignore[no-untyped-def]
"""在 WSGI 线程中通过 run_coroutine_threadsafe 运行协程"""
future = asyncio.run_coroutine_threadsafe(coro, EventLoopRef.get())
return future.result()
def _get_session(): # type: ignore[no-untyped-def]
"""获取数据库会话上下文管理器"""
return DatabaseManager._async_session_factory()
# ==================== 异步辅助函数 ====================
async def _get_webdav_account(webdav_id: int) -> WebDAV | None:
"""获取 WebDAV 账户"""
async with _get_session() as session:
return await WebDAV.get(session, WebDAV.id == webdav_id)
async def _get_object_by_path(user_id: UUID, path: str) -> Object | None:
"""根据路径获取对象"""
async with _get_session() as session:
return await Object.get_by_path(session, user_id, path)
async def _get_children(user_id: UUID, parent_id: UUID) -> list[Object]:
"""获取目录子对象"""
async with _get_session() as session:
return await Object.get_children(session, user_id, parent_id)
async def _get_object_by_id(object_id: UUID) -> Object | None:
"""根据ID获取对象"""
async with _get_session() as session:
return await Object.get(session, Object.id == object_id, load=Object.physical_file)
async def _get_user(user_id: UUID) -> User | None:
"""获取用户(含 group 关系)"""
async with _get_session() as session:
return await User.get(session, User.id == user_id, load=User.group)
async def _get_policy(policy_id: UUID) -> Policy | None:
"""获取存储策略"""
async with _get_session() as session:
return await Policy.get(session, Policy.id == policy_id)
async def _create_folder(
name: str,
parent_id: UUID,
owner_id: UUID,
policy_id: UUID,
) -> Object:
"""创建目录对象"""
async with _get_session() as session:
obj = Object(
name=name,
type=ObjectType.FOLDER,
size=0,
parent_id=parent_id,
owner_id=owner_id,
policy_id=policy_id,
)
obj = await obj.save(session)
return obj
async def _create_file(
name: str,
parent_id: UUID,
owner_id: UUID,
policy_id: UUID,
) -> Object:
"""创建空文件对象"""
async with _get_session() as session:
obj = Object(
name=name,
type=ObjectType.FILE,
size=0,
parent_id=parent_id,
owner_id=owner_id,
policy_id=policy_id,
)
obj = await obj.save(session)
return obj
async def _soft_delete_object(object_id: UUID) -> None:
"""软删除对象(移入回收站)"""
from service.storage import soft_delete_objects
async with _get_session() as session:
obj = await Object.get(session, Object.id == object_id)
if obj:
await soft_delete_objects(session, [obj])
async def _finalize_upload(
object_id: UUID,
physical_path: str,
size: int,
owner_id: UUID,
policy_id: UUID,
) -> None:
"""上传完成后更新对象元数据和物理文件记录"""
async with _get_session() as session:
# 获取存储路径(相对路径)
policy = await Policy.get(session, Policy.id == policy_id)
if not policy or not policy.server:
raise DAVError(HTTP_NOT_FOUND, "存储策略不存在")
base_path = Path(policy.server).resolve()
full_path = Path(physical_path).resolve()
storage_path = str(full_path.relative_to(base_path))
# 创建 PhysicalFile 记录
pf = PhysicalFile(
storage_path=storage_path,
size=size,
policy_id=policy_id,
reference_count=1,
)
pf = await pf.save(session)
# 更新 Object
obj = await Object.get(session, Object.id == object_id)
if obj:
obj.sqlmodel_update({'size': size, 'physical_file_id': pf.id})
session.add(obj)
await session.commit()
# 更新用户存储用量
if size > 0:
await adjust_user_storage(session, owner_id, size)
async def _move_object(
object_id: UUID,
new_parent_id: UUID,
new_name: str,
) -> None:
"""移动/重命名对象"""
async with _get_session() as session:
obj = await Object.get(session, Object.id == object_id)
if obj:
obj.sqlmodel_update({'parent_id': new_parent_id, 'name': new_name})
session.add(obj)
await session.commit()
async def _copy_object_recursive(
src_id: UUID,
dst_parent_id: UUID,
dst_name: str,
owner_id: UUID,
) -> None:
"""递归复制对象"""
from service.storage import copy_object_recursive
async with _get_session() as session:
src = await Object.get(session, Object.id == src_id)
if not src:
return
await copy_object_recursive(session, src, dst_parent_id, owner_id, new_name=dst_name)
# ==================== 辅助工具 ====================
def _get_environ_info(environ: dict[str, object]) -> tuple[UUID, int]:
"""从 environ 中提取认证信息"""
user_id: UUID = environ["disknext.user_id"] # type: ignore[assignment]
webdav_id: int = environ["disknext.webdav_id"] # type: ignore[assignment]
return user_id, webdav_id
def _resolve_dav_path(account_root: str, dav_path: str) -> str:
"""
将 DAV 相对路径映射到 DiskNext 绝对路径。
:param account_root: 账户挂载根路径,如 "/""/docs"
:param dav_path: DAV 请求路径,如 "/""/photos/cat.jpg"
:return: DiskNext 内部路径,如 "/docs/photos/cat.jpg"
"""
# 规范化根路径
root = account_root.rstrip("/")
if not root:
root = ""
# 规范化 DAV 路径
if not dav_path or dav_path == "/":
return root + "/" if root else "/"
if not dav_path.startswith("/"):
dav_path = "/" + dav_path
full = root + dav_path
return full if full else "/"
def _check_readonly(environ: dict[str, object]) -> None:
"""检查账户是否只读,只读则抛出 403"""
account = environ.get("disknext.webdav_account")
if account and getattr(account, 'readonly', False):
raise DAVError(HTTP_FORBIDDEN, "WebDAV 账户为只读模式")
def _check_storage_quota(user: User, additional_bytes: int) -> None:
"""检查存储配额"""
max_storage = user.group.max_storage
if max_storage > 0 and user.storage + additional_bytes > max_storage:
raise DAVError(HTTP_INSUFFICIENT_STORAGE, "存储空间不足")
# ==================== Provider ====================
class DiskNextDAVProvider(DAVProvider):
"""DiskNext WebDAV 存储 Provider"""
def __init__(self) -> None:
super().__init__()
def get_resource_inst(
self,
path: str,
environ: dict[str, object],
) -> 'DiskNextCollection | DiskNextFile | None':
"""
将 WebDAV 路径映射到资源对象。
首次调用时加载 WebDAV 账户信息并缓存到 environ。
"""
user_id, webdav_id = _get_environ_info(environ)
# 首次请求时加载账户信息
if "disknext.webdav_account" not in environ:
account = _run_async(_get_webdav_account(webdav_id))
if not account:
return None
environ["disknext.webdav_account"] = account
account: WebDAV = environ["disknext.webdav_account"] # type: ignore[no-redef]
disknext_path = _resolve_dav_path(account.root, path)
obj = _run_async(_get_object_by_path(user_id, disknext_path))
if not obj:
return None
if obj.is_folder:
return DiskNextCollection(path, environ, obj, user_id, account)
else:
return DiskNextFile(path, environ, obj, user_id, account)
def is_readonly(self) -> bool:
"""只读由账户级别控制,不在 provider 级别限制"""
return False
# ==================== Collection目录 ====================
class DiskNextCollection(DAVCollection):
"""DiskNext 目录资源"""
def __init__(
self,
path: str,
environ: dict[str, object],
obj: Object,
user_id: UUID,
account: WebDAV,
) -> None:
super().__init__(path, environ)
self._obj = obj
self._user_id = user_id
self._account = account
def get_display_info(self) -> dict[str, str]:
return {"type": "Directory"}
def get_member_names(self) -> list[str]:
"""获取子对象名称列表"""
children = _run_async(_get_children(self._user_id, self._obj.id))
return [c.name for c in children]
def get_member(self, name: str) -> 'DiskNextCollection | DiskNextFile | None':
"""获取指定名称的子资源"""
member_path = self.path.rstrip("/") + "/" + name
account_root = self._account.root
disknext_path = _resolve_dav_path(account_root, member_path)
obj = _run_async(_get_object_by_path(self._user_id, disknext_path))
if not obj:
return None
if obj.is_folder:
return DiskNextCollection(member_path, self.environ, obj, self._user_id, self._account)
else:
return DiskNextFile(member_path, self.environ, obj, self._user_id, self._account)
def get_creation_date(self) -> float | None:
if self._obj.created_at:
return self._obj.created_at.timestamp()
return None
def get_last_modified(self) -> float | None:
if self._obj.updated_at:
return self._obj.updated_at.timestamp()
return None
def create_empty_resource(self, name: str) -> 'DiskNextFile':
"""创建空文件PUT 操作的第一步)"""
_check_readonly(self.environ)
obj = _run_async(_create_file(
name=name,
parent_id=self._obj.id,
owner_id=self._user_id,
policy_id=self._obj.policy_id,
))
member_path = self.path.rstrip("/") + "/" + name
return DiskNextFile(member_path, self.environ, obj, self._user_id, self._account)
def create_collection(self, name: str) -> 'DiskNextCollection':
"""创建子目录MKCOL"""
_check_readonly(self.environ)
obj = _run_async(_create_folder(
name=name,
parent_id=self._obj.id,
owner_id=self._user_id,
policy_id=self._obj.policy_id,
))
member_path = self.path.rstrip("/") + "/" + name
return DiskNextCollection(member_path, self.environ, obj, self._user_id, self._account)
def delete(self) -> None:
"""软删除目录"""
_check_readonly(self.environ)
_run_async(_soft_delete_object(self._obj.id))
def copy_move_single(self, dest_path: str, *, is_move: bool) -> bool:
"""复制或移动目录"""
_check_readonly(self.environ)
account_root = self._account.root
dest_disknext = _resolve_dav_path(account_root, dest_path)
# 解析目标父路径和新名称
if "/" in dest_disknext.rstrip("/"):
parent_path = dest_disknext.rsplit("/", 1)[0] or "/"
new_name = dest_disknext.rsplit("/", 1)[1]
else:
parent_path = "/"
new_name = dest_disknext.lstrip("/")
dest_parent = _run_async(_get_object_by_path(self._user_id, parent_path))
if not dest_parent:
raise DAVError(HTTP_NOT_FOUND, "目标父目录不存在")
if is_move:
_run_async(_move_object(self._obj.id, dest_parent.id, new_name))
else:
_run_async(_copy_object_recursive(
self._obj.id, dest_parent.id, new_name, self._user_id,
))
return True
def support_recursive_delete(self) -> bool:
return True
def support_recursive_move(self, dest_path: str) -> bool:
return True
# ==================== NonCollection文件 ====================
class DiskNextFile(DAVNonCollection):
"""DiskNext 文件资源"""
def __init__(
self,
path: str,
environ: dict[str, object],
obj: Object,
user_id: UUID,
account: WebDAV,
) -> None:
super().__init__(path, environ)
self._obj = obj
self._user_id = user_id
self._account = account
self._write_path: str | None = None
self._write_stream: io.BufferedWriter | None = None
def get_content_length(self) -> int | None:
return self._obj.size if self._obj.size else 0
def get_content_type(self) -> str | None:
# 尝试从文件名推断 MIME 类型
mime, _ = mimetypes.guess_type(self._obj.name)
return mime or "application/octet-stream"
def get_creation_date(self) -> float | None:
if self._obj.created_at:
return self._obj.created_at.timestamp()
return None
def get_last_modified(self) -> float | None:
if self._obj.updated_at:
return self._obj.updated_at.timestamp()
return None
def get_display_info(self) -> dict[str, str]:
return {"type": "File"}
def get_content(self) -> io.BufferedReader | None:
"""
返回文件内容的可读流。
WsgiDAV 在线程中运行,可安全使用同步 open()。
"""
obj_with_file = _run_async(_get_object_by_id(self._obj.id))
if not obj_with_file or not obj_with_file.physical_file:
return None
pf = obj_with_file.physical_file
policy = _run_async(_get_policy(obj_with_file.policy_id))
if not policy or not policy.server:
return None
full_path = Path(policy.server).resolve() / pf.storage_path
if not full_path.is_file():
l.warning(f"WebDAV: 物理文件不存在: {full_path}")
return None
return open(full_path, "rb") # noqa: SIM115
def begin_write(self, *, content_type: str | None = None) -> io.BufferedWriter:
"""
开始写入文件PUT 操作)。
返回一个可写的文件流WsgiDAV 将向其中写入请求体数据。
"""
_check_readonly(self.environ)
# 检查配额
user = _run_async(_get_user(self._user_id))
if user:
content_length = self.environ.get("CONTENT_LENGTH")
if content_length:
_check_storage_quota(user, int(content_length))
# 获取策略以确定存储路径
policy = _run_async(_get_policy(self._obj.policy_id))
if not policy or not policy.server:
raise DAVError(HTTP_NOT_FOUND, "存储策略不存在")
storage_service = LocalStorageService(policy)
dir_path, storage_name, full_path = _run_async(
storage_service.generate_file_path(
user_id=self._user_id,
original_filename=self._obj.name,
)
)
self._write_path = full_path
self._write_stream = open(full_path, "wb") # noqa: SIM115
return self._write_stream
def end_write(self, *, with_errors: bool) -> None:
"""写入完成后的收尾工作"""
if self._write_stream:
self._write_stream.close()
self._write_stream = None
if with_errors or not self._write_path:
return
# 获取文件大小
file_path = Path(self._write_path)
if not file_path.exists():
return
size = file_path.stat().st_size
# 更新数据库记录
_run_async(_finalize_upload(
object_id=self._obj.id,
physical_path=self._write_path,
size=size,
owner_id=self._user_id,
policy_id=self._obj.policy_id,
))
l.debug(f"WebDAV 文件写入完成: {self._obj.name}, size={size}")
def delete(self) -> None:
"""软删除文件"""
_check_readonly(self.environ)
_run_async(_soft_delete_object(self._obj.id))
def copy_move_single(self, dest_path: str, *, is_move: bool) -> bool:
"""复制或移动文件"""
_check_readonly(self.environ)
account_root = self._account.root
dest_disknext = _resolve_dav_path(account_root, dest_path)
# 解析目标父路径和新名称
if "/" in dest_disknext.rstrip("/"):
parent_path = dest_disknext.rsplit("/", 1)[0] or "/"
new_name = dest_disknext.rsplit("/", 1)[1]
else:
parent_path = "/"
new_name = dest_disknext.lstrip("/")
dest_parent = _run_async(_get_object_by_path(self._user_id, parent_path))
if not dest_parent:
raise DAVError(HTTP_NOT_FOUND, "目标父目录不存在")
if is_move:
_run_async(_move_object(self._obj.id, dest_parent.id, new_name))
else:
_run_async(_copy_object_recursive(
self._obj.id, dest_parent.id, new_name, self._user_id,
))
return True
def support_content_length(self) -> bool:
return True
def get_etag(self) -> str | None:
"""返回 ETag基于ID和更新时间WsgiDAV 会自动加双引号"""
if self._obj.updated_at:
return f"{self._obj.id}-{int(self._obj.updated_at.timestamp())}"
return None
def support_etag(self) -> bool:
return True
def support_ranges(self) -> bool:
return True

View File

@@ -0,0 +1,128 @@
"""
WebDAV 认证缓存
缓存 HTTP Basic Auth 的认证结果,避免每次请求都查库 + Argon2 验证。
支持 Redis首选和内存缓存降级两种存储后端。
"""
import hashlib
from typing import ClassVar
from uuid import UUID
from cachetools import TTLCache
from loguru import logger as l
from . import RedisManager
_AUTH_TTL: int = 300
"""认证缓存 TTL5 分钟"""
class WebDAVAuthCache:
"""
WebDAV 认证结果缓存
缓存键格式: webdav_auth:{email}/{account_name}:{sha256(password)}
缓存值格式: {user_id}:{webdav_id}
密码的 SHA256 作为缓存键的一部分,密码变更后旧缓存自然 miss。
"""
_memory_cache: ClassVar[TTLCache[str, str]] = TTLCache(maxsize=10000, ttl=_AUTH_TTL)
"""内存缓存降级方案"""
@classmethod
def _build_key(cls, email: str, account_name: str, password: str) -> str:
"""构建缓存键"""
pwd_hash = hashlib.sha256(password.encode()).hexdigest()[:16]
return f"webdav_auth:{email}/{account_name}:{pwd_hash}"
@classmethod
async def get(
cls,
email: str,
account_name: str,
password: str,
) -> tuple[UUID, int] | None:
"""
查询缓存中的认证结果。
:param email: 用户邮箱
:param account_name: WebDAV 账户名
:param password: 用户提供的明文密码
:return: (user_id, webdav_id) 或 None缓存未命中
"""
key = cls._build_key(email, account_name, password)
client = RedisManager.get_client()
if client is not None:
value = await client.get(key)
if value is not None:
raw = value.decode() if isinstance(value, bytes) else value
user_id_str, webdav_id_str = raw.split(":", 1)
return UUID(user_id_str), int(webdav_id_str)
else:
raw = cls._memory_cache.get(key)
if raw is not None:
user_id_str, webdav_id_str = raw.split(":", 1)
return UUID(user_id_str), int(webdav_id_str)
return None
@classmethod
async def set(
cls,
email: str,
account_name: str,
password: str,
user_id: UUID,
webdav_id: int,
) -> None:
"""
写入认证结果到缓存。
:param email: 用户邮箱
:param account_name: WebDAV 账户名
:param password: 用户提供的明文密码
:param user_id: 用户UUID
:param webdav_id: WebDAV 账户ID
"""
key = cls._build_key(email, account_name, password)
value = f"{user_id}:{webdav_id}"
client = RedisManager.get_client()
if client is not None:
await client.set(key, value, ex=_AUTH_TTL)
else:
cls._memory_cache[key] = value
@classmethod
async def invalidate_account(cls, user_id: UUID, account_name: str) -> None:
"""
失效指定账户的所有缓存。
由于缓存键包含 password hash无法精确删除
Redis 端使用 pattern scan 删除,内存端清空全部。
:param user_id: 用户UUID
:param account_name: WebDAV 账户名
"""
client = RedisManager.get_client()
if client is not None:
pattern = f"webdav_auth:*/{account_name}:*"
cursor: int = 0
while True:
cursor, keys = await client.scan(cursor, match=pattern, count=100)
if keys:
await client.delete(*keys)
if cursor == 0:
break
else:
# 内存缓存无法按 pattern 删除,清除所有含该账户名的条目
keys_to_delete = [
k for k in cls._memory_cache
if f"/{account_name}:" in k
]
for k in keys_to_delete:
cls._memory_cache.pop(k, None)
l.debug(f"已清除 WebDAV 认证缓存: user={user_id}, account={account_name}")

View File

@@ -75,7 +75,9 @@ from .object import (
ObjectBase, ObjectBase,
ObjectCopyRequest, ObjectCopyRequest,
ObjectDeleteRequest, ObjectDeleteRequest,
ObjectFileFinalize,
ObjectMoveRequest, ObjectMoveRequest,
ObjectMoveUpdate,
ObjectPropertyDetailResponse, ObjectPropertyDetailResponse,
ObjectPropertyResponse, ObjectPropertyResponse,
ObjectRenameRequest, ObjectRenameRequest,
@@ -115,7 +117,10 @@ from .source_link import SourceLink
from .storage_pack import StoragePack from .storage_pack import StoragePack
from .tag import Tag, TagType from .tag import Tag, TagType
from .task import Task, TaskProps, TaskPropsBase, TaskStatus, TaskType, TaskSummary from .task import Task, TaskProps, TaskPropsBase, TaskStatus, TaskType, TaskSummary
from .webdav import WebDAV from .webdav import (
WebDAV, WebDAVBase,
WebDAVCreateRequest, WebDAVUpdateRequest, WebDAVAccountResponse,
)
from .file_app import ( from .file_app import (
FileApp, FileAppType, FileAppExtension, FileAppGroupLink, UserFileAppDefault, FileApp, FileAppType, FileAppExtension, FileAppGroupLink, UserFileAppDefault,
# DTO # DTO

View File

@@ -78,6 +78,26 @@ class ObjectBase(SQLModelBase):
# ==================== DTO 模型 ==================== # ==================== DTO 模型 ====================
class ObjectFileFinalize(SQLModelBase):
"""文件上传完成后更新 Object 的 DTO"""
size: int
"""文件大小(字节)"""
physical_file_id: UUID
"""关联的物理文件UUID"""
class ObjectMoveUpdate(SQLModelBase):
"""移动/重命名 Object 的 DTO"""
parent_id: UUID
"""新的父目录UUID"""
name: str
"""新名称"""
class DirectoryCreateRequest(SQLModelBase): class DirectoryCreateRequest(SQLModelBase):
"""创建目录请求 DTO""" """创建目录请求 DTO"""

View File

@@ -1,4 +1,9 @@
"""
WebDAV 账户模型
管理用户的 WebDAV 连接账户,每个账户对应一个挂载根路径。
通过 HTTP Basic Auth 认证访问 DAV 协议端点。
"""
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from uuid import UUID from uuid import UUID
@@ -9,24 +14,104 @@ from sqlmodel_ext import SQLModelBase, TableBaseMixin
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
class WebDAV(SQLModelBase, TableBaseMixin):
"""WebDAV账户模型""" # ==================== Base 模型 ====================
class WebDAVBase(SQLModelBase):
"""WebDAV 账户基础字段"""
name: str = Field(max_length=255)
"""账户名称(同一用户下唯一)"""
root: str = Field(default="/", sa_column_kwargs={"server_default": "'/'"})
"""挂载根目录路径"""
readonly: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
"""是否只读"""
use_proxy: bool = Field(default=False, sa_column_kwargs={"server_default": "false"})
"""是否使用代理下载"""
# ==================== 数据库模型 ====================
class WebDAV(WebDAVBase, TableBaseMixin):
"""WebDAV 账户模型"""
__table_args__ = (UniqueConstraint("name", "user_id", name="uq_webdav_name_user"),) __table_args__ = (UniqueConstraint("name", "user_id", name="uq_webdav_name_user"),)
name: str = Field(max_length=255, description="WebDAV账户名") password: str = Field(max_length=255)
password: str = Field(max_length=255, description="WebDAV密码") """密码Argon2 哈希)"""
root: str = Field(default="/", sa_column_kwargs={"server_default": "'/'"}, description="根目录路径")
readonly: bool = Field(default=False, description="是否只读")
use_proxy: bool = Field(default=False, description="是否使用代理下载")
# 外键 # 外键
user_id: UUID = Field( user_id: UUID = Field(
foreign_key="user.id", foreign_key="user.id",
index=True, index=True,
ondelete="CASCADE" ondelete="CASCADE",
) )
"""所属用户UUID""" """所属用户UUID"""
# 关系 # 关系
user: "User" = Relationship(back_populates="webdavs") user: "User" = Relationship(back_populates="webdavs")
# ==================== DTO 模型 ====================
class WebDAVCreateRequest(SQLModelBase):
"""创建 WebDAV 账户请求"""
name: str = Field(max_length=255)
"""账户名称"""
password: str = Field(min_length=1, max_length=255)
"""账户密码(明文,服务端哈希后存储)"""
root: str = "/"
"""挂载根目录路径"""
readonly: bool = False
"""是否只读"""
use_proxy: bool = False
"""是否使用代理下载"""
class WebDAVUpdateRequest(SQLModelBase):
"""更新 WebDAV 账户请求"""
password: str | None = Field(default=None, min_length=1, max_length=255)
"""新密码(为 None 时不修改)"""
root: str | None = None
"""新挂载根目录路径(为 None 时不修改)"""
readonly: bool | None = None
"""是否只读(为 None 时不修改)"""
use_proxy: bool | None = None
"""是否使用代理下载(为 None 时不修改)"""
class WebDAVAccountResponse(SQLModelBase):
"""WebDAV 账户响应"""
id: int
"""账户ID"""
name: str
"""账户名称"""
root: str
"""挂载根目录路径"""
readonly: bool
"""是否只读"""
use_proxy: bool
"""是否使用代理下载"""
created_at: str
"""创建时间"""
updated_at: str
"""更新时间"""

View File

@@ -0,0 +1,591 @@
"""
WebDAV 账户管理端点集成测试
"""
from uuid import UUID, uuid4
import pytest
import pytest_asyncio
from httpx import AsyncClient
from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodels import Group, GroupClaims, GroupOptions, Object, ObjectType, User
from sqlmodels.auth_identity import AuthIdentity, AuthProviderType
from sqlmodels.user import UserStatus
from utils import Password
from utils.JWT import create_access_token
API_PREFIX = "/api/v1/webdav"
# ==================== Fixtures ====================
@pytest_asyncio.fixture
async def no_webdav_headers(initialized_db: AsyncSession) -> dict[str, str]:
"""创建一个 WebDAV 被禁用的用户,返回其认证头"""
group = Group(
id=uuid4(),
name="无WebDAV用户组",
max_storage=1024 * 1024 * 1024,
share_enabled=True,
web_dav_enabled=False,
admin=False,
speed_limit=0,
)
initialized_db.add(group)
await initialized_db.commit()
await initialized_db.refresh(group)
group_options = GroupOptions(
group_id=group.id,
share_download=True,
share_free=False,
relocate=False,
source_batch=0,
select_node=False,
advance_delete=False,
)
initialized_db.add(group_options)
await initialized_db.commit()
await initialized_db.refresh(group_options)
user = User(
id=uuid4(),
email="nowebdav@test.local",
nickname="无WebDAV用户",
status=UserStatus.ACTIVE,
storage=0,
score=0,
group_id=group.id,
avatar="default",
)
initialized_db.add(user)
await initialized_db.commit()
await initialized_db.refresh(user)
identity = AuthIdentity(
provider=AuthProviderType.EMAIL_PASSWORD,
identifier="nowebdav@test.local",
credential=Password.hash("nowebdav123"),
is_primary=True,
is_verified=True,
user_id=user.id,
)
initialized_db.add(identity)
from sqlmodels import Policy
policy = await Policy.get(initialized_db, Policy.name == "本地存储")
root = Object(
id=uuid4(),
name="/",
type=ObjectType.FOLDER,
owner_id=user.id,
parent_id=None,
policy_id=policy.id,
size=0,
)
initialized_db.add(root)
await initialized_db.commit()
group.options = group_options
group_claims = GroupClaims.from_group(group)
result = create_access_token(
sub=user.id,
jti=uuid4(),
status=user.status.value,
group=group_claims,
)
return {"Authorization": f"Bearer {result.access_token}"}
# ==================== 认证测试 ====================
@pytest.mark.asyncio
async def test_list_accounts_requires_auth(async_client: AsyncClient):
"""测试获取账户列表需要认证"""
response = await async_client.get(f"{API_PREFIX}/accounts")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_create_account_requires_auth(async_client: AsyncClient):
"""测试创建账户需要认证"""
response = await async_client.post(
f"{API_PREFIX}/accounts",
json={"name": "test", "password": "testpass"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_update_account_requires_auth(async_client: AsyncClient):
"""测试更新账户需要认证"""
response = await async_client.patch(
f"{API_PREFIX}/accounts/1",
json={"readonly": True},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_delete_account_requires_auth(async_client: AsyncClient):
"""测试删除账户需要认证"""
response = await async_client.delete(f"{API_PREFIX}/accounts/1")
assert response.status_code == 401
# ==================== WebDAV 禁用测试 ====================
@pytest.mark.asyncio
async def test_list_accounts_webdav_disabled(
async_client: AsyncClient,
no_webdav_headers: dict[str, str],
):
"""测试 WebDAV 被禁用时返回 403"""
response = await async_client.get(
f"{API_PREFIX}/accounts",
headers=no_webdav_headers,
)
assert response.status_code == 403
@pytest.mark.asyncio
async def test_create_account_webdav_disabled(
async_client: AsyncClient,
no_webdav_headers: dict[str, str],
):
"""测试 WebDAV 被禁用时创建账户返回 403"""
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=no_webdav_headers,
json={"name": "test", "password": "testpass"},
)
assert response.status_code == 403
# ==================== 获取账户列表测试 ====================
@pytest.mark.asyncio
async def test_list_accounts_empty(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试初始状态账户列表为空"""
response = await async_client.get(
f"{API_PREFIX}/accounts",
headers=auth_headers,
)
assert response.status_code == 200
assert response.json() == []
# ==================== 创建账户测试 ====================
@pytest.mark.asyncio
async def test_create_account_success(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试成功创建 WebDAV 账户"""
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "my-nas", "password": "secretpass"},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "my-nas"
assert data["root"] == "/"
assert data["readonly"] is False
assert data["use_proxy"] is False
assert "id" in data
assert "created_at" in data
assert "updated_at" in data
@pytest.mark.asyncio
async def test_create_account_with_options(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试创建带选项的 WebDAV 账户"""
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={
"name": "readonly-nas",
"password": "secretpass",
"readonly": True,
"use_proxy": True,
},
)
assert response.status_code == 201
data = response.json()
assert data["name"] == "readonly-nas"
assert data["readonly"] is True
assert data["use_proxy"] is True
@pytest.mark.asyncio
async def test_create_account_duplicate_name(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试重名账户返回 409"""
# 先创建一个
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "dup-test", "password": "pass1"},
)
assert response.status_code == 201
# 再创建同名的
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "dup-test", "password": "pass2"},
)
assert response.status_code == 409
@pytest.mark.asyncio
async def test_create_account_invalid_root(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试无效根目录路径返回 400"""
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={
"name": "bad-root",
"password": "secretpass",
"root": "/nonexistent/path",
},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_create_account_with_valid_subdir(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试使用有效的子目录作为根路径"""
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={
"name": "docs-only",
"password": "secretpass",
"root": "/docs",
},
)
assert response.status_code == 201
assert response.json()["root"] == "/docs"
# ==================== 列表包含已创建账户测试 ====================
@pytest.mark.asyncio
async def test_list_accounts_after_create(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试创建后列表中包含该账户"""
# 创建
await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "list-test", "password": "pass"},
)
# 列表
response = await async_client.get(
f"{API_PREFIX}/accounts",
headers=auth_headers,
)
assert response.status_code == 200
accounts = response.json()
assert len(accounts) == 1
assert accounts[0]["name"] == "list-test"
# ==================== 更新账户测试 ====================
@pytest.mark.asyncio
async def test_update_account_success(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试成功更新 WebDAV 账户"""
# 创建
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "update-test", "password": "oldpass"},
)
account_id = create_resp.json()["id"]
# 更新
response = await async_client.patch(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
json={"readonly": True},
)
assert response.status_code == 200
data = response.json()
assert data["readonly"] is True
assert data["name"] == "update-test"
@pytest.mark.asyncio
async def test_update_account_password(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试更新密码"""
# 创建
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "pwd-test", "password": "oldpass"},
)
account_id = create_resp.json()["id"]
# 更新密码
response = await async_client.patch(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
json={"password": "newpass123"},
)
assert response.status_code == 200
@pytest.mark.asyncio
async def test_update_account_root(
async_client: AsyncClient,
auth_headers: dict[str, str],
test_directory_structure: dict[str, UUID],
):
"""测试更新根目录路径"""
# 创建
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "root-update", "password": "pass"},
)
account_id = create_resp.json()["id"]
# 更新 root 到有效子目录
response = await async_client.patch(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
json={"root": "/docs"},
)
assert response.status_code == 200
assert response.json()["root"] == "/docs"
@pytest.mark.asyncio
async def test_update_account_invalid_root(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试更新为无效根目录返回 400"""
# 创建
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "bad-root-update", "password": "pass"},
)
account_id = create_resp.json()["id"]
# 更新到无效路径
response = await async_client.patch(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
json={"root": "/nonexistent"},
)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_update_account_not_found(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试更新不存在的账户返回 404"""
response = await async_client.patch(
f"{API_PREFIX}/accounts/99999",
headers=auth_headers,
json={"readonly": True},
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_update_other_user_account(
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_headers: dict[str, str],
):
"""测试更新其他用户的账户返回 404"""
# 管理员创建账户
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=admin_headers,
json={"name": "admin-account", "password": "pass"},
)
account_id = create_resp.json()["id"]
# 普通用户尝试更新
response = await async_client.patch(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
json={"readonly": True},
)
assert response.status_code == 404
# ==================== 删除账户测试 ====================
@pytest.mark.asyncio
async def test_delete_account_success(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试成功删除 WebDAV 账户"""
# 创建
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "delete-test", "password": "pass"},
)
account_id = create_resp.json()["id"]
# 删除
response = await async_client.delete(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
)
assert response.status_code == 204
# 确认列表中已不存在
list_resp = await async_client.get(
f"{API_PREFIX}/accounts",
headers=auth_headers,
)
assert list_resp.status_code == 200
names = [a["name"] for a in list_resp.json()]
assert "delete-test" not in names
@pytest.mark.asyncio
async def test_delete_account_not_found(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试删除不存在的账户返回 404"""
response = await async_client.delete(
f"{API_PREFIX}/accounts/99999",
headers=auth_headers,
)
assert response.status_code == 404
@pytest.mark.asyncio
async def test_delete_other_user_account(
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_headers: dict[str, str],
):
"""测试删除其他用户的账户返回 404"""
# 管理员创建账户
create_resp = await async_client.post(
f"{API_PREFIX}/accounts",
headers=admin_headers,
json={"name": "admin-del-test", "password": "pass"},
)
account_id = create_resp.json()["id"]
# 普通用户尝试删除
response = await async_client.delete(
f"{API_PREFIX}/accounts/{account_id}",
headers=auth_headers,
)
assert response.status_code == 404
# ==================== 多账户测试 ====================
@pytest.mark.asyncio
async def test_multiple_accounts(
async_client: AsyncClient,
auth_headers: dict[str, str],
):
"""测试同一用户可以创建多个账户"""
for name in ["account-1", "account-2", "account-3"]:
response = await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": name, "password": "pass"},
)
assert response.status_code == 201
# 列表应有3个
response = await async_client.get(
f"{API_PREFIX}/accounts",
headers=auth_headers,
)
assert response.status_code == 200
assert len(response.json()) == 3
# ==================== 用户隔离测试 ====================
@pytest.mark.asyncio
async def test_accounts_user_isolation(
async_client: AsyncClient,
auth_headers: dict[str, str],
admin_headers: dict[str, str],
):
"""测试不同用户的账户相互隔离"""
# 普通用户创建
await async_client.post(
f"{API_PREFIX}/accounts",
headers=auth_headers,
json={"name": "user-account", "password": "pass"},
)
# 管理员创建
await async_client.post(
f"{API_PREFIX}/accounts",
headers=admin_headers,
json={"name": "admin-account", "password": "pass"},
)
# 普通用户只看到自己的
response = await async_client.get(
f"{API_PREFIX}/accounts",
headers=auth_headers,
)
assert response.status_code == 200
accounts = response.json()
assert len(accounts) == 1
assert accounts[0]["name"] == "user-account"
# 管理员只看到自己的
response = await async_client.get(
f"{API_PREFIX}/accounts",
headers=admin_headers,
)
assert response.status_code == 200
accounts = response.json()
assert len(accounts) == 1
assert accounts[0]["name"] == "admin-account"

46
uv.lock generated
View File

@@ -6,6 +6,15 @@ resolution-markers = [
"python_full_version < '3.14'", "python_full_version < '3.14'",
] ]
[[package]]
name = "a2wsgi"
version = "1.10.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9a/cb/822c56fbea97e9eee201a2e434a80437f6750ebcb1ed307ee3a0a7505b14/a2wsgi-1.10.10.tar.gz", hash = "sha256:a5bcffb52081ba39df0d5e9a884fc6f819d92e3a42389343ba77cbf809fe1f45", size = 18799, upload-time = "2025-06-18T09:00:10.843Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/d5/349aba3dc421e73cbd4958c0ce0a4f1aa3a738bc0d7de75d2f40ed43a535/a2wsgi-1.10.10-py3-none-any.whl", hash = "sha256:d2b21379479718539dc15fce53b876251a0efe7615352dfe49f6ad1bc507848d", size = 17389, upload-time = "2025-06-18T09:00:09.676Z" },
]
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
version = "25.1.0" version = "25.1.0"
@@ -500,11 +509,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" }, { url = "https://files.pythonhosted.org/packages/ff/fa/d3c15189f7c52aaefbaea76fb012119b04b9013f4bf446cb4eb4c26c4e6b/cython-3.2.4-py3-none-any.whl", hash = "sha256:732fc93bc33ae4b14f6afaca663b916c2fdd5dcbfad7114e17fb2434eeaea45c", size = 1257078, upload-time = "2026-01-04T14:14:12.373Z" },
] ]
[[package]]
name = "defusedxml"
version = "0.7.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
]
[[package]] [[package]]
name = "disknext-server" name = "disknext-server"
version = "0.0.1" version = "0.0.1"
source = { virtual = "." } source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "a2wsgi" },
{ name = "aiofiles" }, { name = "aiofiles" },
{ name = "aiohttp" }, { name = "aiohttp" },
{ name = "aiosqlite" }, { name = "aiosqlite" },
@@ -533,6 +552,7 @@ dependencies = [
{ name = "uvicorn" }, { name = "uvicorn" },
{ name = "webauthn" }, { name = "webauthn" },
{ name = "whatthepatch" }, { name = "whatthepatch" },
{ name = "wsgidav" },
] ]
[package.optional-dependencies] [package.optional-dependencies]
@@ -543,6 +563,7 @@ build = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "a2wsgi", specifier = ">=1.10.0" },
{ name = "aiofiles", specifier = ">=25.1.0" }, { name = "aiofiles", specifier = ">=25.1.0" },
{ name = "aiohttp", specifier = ">=3.13.2" }, { name = "aiohttp", specifier = ">=3.13.2" },
{ name = "aiosqlite", specifier = "==0.22.1" }, { name = "aiosqlite", specifier = "==0.22.1" },
@@ -573,6 +594,7 @@ requires-dist = [
{ name = "uvicorn", specifier = ">=0.38.0" }, { name = "uvicorn", specifier = ">=0.38.0" },
{ name = "webauthn", specifier = ">=2.7.0" }, { name = "webauthn", specifier = ">=2.7.0" },
{ name = "whatthepatch", specifier = ">=1.0.6" }, { name = "whatthepatch", specifier = ">=1.0.6" },
{ name = "wsgidav", specifier = ">=4.3.0" },
] ]
provides-extras = ["build"] provides-extras = ["build"]
@@ -975,6 +997,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
] ]
[[package]]
name = "json5"
version = "0.13.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/77/e8/a3f261a66e4663f22700bc8a17c08cb83e91fbf086726e7a228398968981/json5-0.13.0.tar.gz", hash = "sha256:b1edf8d487721c0bf64d83c28e91280781f6e21f4a797d3261c7c828d4c165bf", size = 52441, upload-time = "2026-01-01T19:42:14.99Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/9e/038522f50ceb7e74f1f991bf1b699f24b0c2bbe7c390dd36ad69f4582258/json5-0.13.0-py3-none-any.whl", hash = "sha256:9a08e1dd65f6a4d4c6fa82d216cf2477349ec2346a38fd70cc11d2557499fbcc", size = 36163, upload-time = "2026-01-01T19:42:13.962Z" },
]
[[package]] [[package]]
name = "loguru" name = "loguru"
version = "0.7.3" version = "0.7.3"
@@ -2049,6 +2080,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" }, { url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083, upload-time = "2024-12-07T15:28:26.465Z" },
] ]
[[package]]
name = "wsgidav"
version = "4.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "defusedxml" },
{ name = "jinja2" },
{ name = "json5" },
{ name = "pyyaml" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a1/f4/9c89e3e41dc7762cbb005d1baf23381718c7b13607236eacda23b855a288/wsgidav-4.3.3.tar.gz", hash = "sha256:5f0ad71bea72def3018b6ba52da3bcb83f61e0873c27225344582805d6e52b9e", size = 168118, upload-time = "2024-05-04T18:28:01.199Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bb/8e/04fb92513f4deab0f9bf4bdeeebc74f12d4de75ff00ad213c69983fc6563/WsgiDAV-4.3.3-py3-none-any.whl", hash = "sha256:8d96b0f05ad7f280572e99d1c605962a853d715f8e934298555d0c47ef275e88", size = 164954, upload-time = "2024-05-04T18:27:57.718Z" },
]
[[package]] [[package]]
name = "yarl" name = "yarl"
version = "1.22.0" version = "1.22.0"