feat: implement WebDAV protocol support with WsgiDAV + account management API
All checks were successful
Test / test (push) Successful in 2m14s
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:
@@ -1,110 +1,207 @@
|
||||
from typing import Annotated
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from loguru import logger as l
|
||||
|
||||
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.password.pwd import Password
|
||||
|
||||
# WebDAV 管理路由
|
||||
webdav_router = APIRouter(
|
||||
prefix='/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(
|
||||
path='/accounts',
|
||||
summary='获取账号信息',
|
||||
description='Get account information for WebDAV.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
summary='获取账号列表',
|
||||
)
|
||||
def router_webdav_accounts() -> ResponseBase:
|
||||
async def list_accounts(
|
||||
session: SessionDep,
|
||||
user: Annotated[User, Depends(auth_required)],
|
||||
) -> list[WebDAVAccountResponse]:
|
||||
"""
|
||||
Get account information for WebDAV.
|
||||
|
||||
Returns:
|
||||
ResponseBase: A model containing the response data for the account information.
|
||||
列出当前用户所有 WebDAV 账户
|
||||
|
||||
认证:JWT Bearer Token
|
||||
"""
|
||||
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(
|
||||
path='/accounts',
|
||||
summary='新建账号',
|
||||
description='Create a new WebDAV account.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
summary='创建账号',
|
||||
status_code=201,
|
||||
)
|
||||
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.
|
||||
|
||||
Returns:
|
||||
ResponseBase: A model containing the response data for the created account.
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
创建 WebDAV 账户
|
||||
|
||||
@webdav_router.delete(
|
||||
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()
|
||||
认证:JWT Bearer Token
|
||||
|
||||
@webdav_router.post(
|
||||
path='/mount',
|
||||
summary='新建目录挂载',
|
||||
description='Create a new WebDAV mount point.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
)
|
||||
def router_webdav_create_mount() -> ResponseBase:
|
||||
错误处理:
|
||||
- 403: WebDAV 功能未启用
|
||||
- 400: 根目录路径不存在或不是目录
|
||||
- 409: 账户名已存在
|
||||
"""
|
||||
Create a new WebDAV mount point.
|
||||
|
||||
Returns:
|
||||
ResponseBase: A model containing the response data for the created mount point.
|
||||
"""
|
||||
http_exceptions.raise_not_implemented()
|
||||
_check_webdav_enabled(user)
|
||||
user_id: UUID = user.id
|
||||
|
||||
# 验证账户名唯一
|
||||
existing = await WebDAV.get(
|
||||
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(
|
||||
path='accounts/{id}',
|
||||
summary='更新账号信息',
|
||||
description='Update WebDAV account information by ID.',
|
||||
dependencies=[Depends(auth_required)],
|
||||
path='/accounts/{account_id}',
|
||||
summary='更新账号',
|
||||
)
|
||||
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.
|
||||
|
||||
Args:
|
||||
id (str): The ID of the account to be updated.
|
||||
|
||||
Returns:
|
||||
ResponseBase: A model containing the response data for the updated account.
|
||||
更新 WebDAV 账户
|
||||
|
||||
认证:JWT Bearer Token
|
||||
|
||||
错误处理:
|
||||
- 403: WebDAV 功能未启用
|
||||
- 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}")
|
||||
|
||||
Reference in New Issue
Block a user