feat: implement source link endpoints and enforce policy rules
All checks were successful
Test / test (push) Successful in 1m56s
All checks were successful
Test / test (push) Successful in 1m56s
- Add POST/GET source link endpoints for file sharing via permanent URLs - Enforce max_size check in PATCH /file/content to prevent size limit bypass - Support is_private (proxy) vs public (302 redirect) storage modes - Replace all ResponseBase(data=...) with proper DTOs or 204 responses - Add 18 integration tests for source link and policy rule enforcement Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -283,16 +283,17 @@ async def router_admin_get_settings(
|
||||
path='/test',
|
||||
summary='测试 Aria2 连接',
|
||||
description='Test Aria2 RPC connection',
|
||||
dependencies=[Depends(admin_required)]
|
||||
dependencies=[Depends(admin_required)],
|
||||
status_code=204,
|
||||
)
|
||||
async def router_admin_aira2_test(
|
||||
request: Aria2TestRequest,
|
||||
) -> ResponseBase:
|
||||
) -> None:
|
||||
"""
|
||||
测试 Aria2 RPC 连接。
|
||||
|
||||
:param request: 测试请求
|
||||
:return: 测试结果
|
||||
:raises HTTPException: 连接失败时抛出 400
|
||||
"""
|
||||
import aiohttp
|
||||
|
||||
@@ -307,22 +308,18 @@ async def router_admin_aira2_test(
|
||||
async with aiohttp.ClientSession() as client:
|
||||
async with client.post(request.rpc_url, json=payload, timeout=aiohttp.ClientTimeout(total=10)) as resp:
|
||||
if resp.status != 200:
|
||||
return ResponseBase(
|
||||
code=400,
|
||||
msg=f"连接失败,HTTP {resp.status}"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"连接失败,HTTP {resp.status}",
|
||||
)
|
||||
|
||||
result = await resp.json()
|
||||
if "error" in result:
|
||||
return ResponseBase(
|
||||
code=400,
|
||||
msg=f"Aria2 错误: {result['error']['message']}"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Aria2 错误: {result['error']['message']}",
|
||||
)
|
||||
|
||||
version = result.get("result", {}).get("version", "unknown")
|
||||
return ResponseBase(data={
|
||||
"connected": True,
|
||||
"version": version,
|
||||
})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return ResponseBase(code=400, msg=f"连接失败: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}")
|
||||
@@ -55,7 +55,7 @@ async def router_admin_get_groups(
|
||||
async def router_admin_get_group(
|
||||
session: SessionDep,
|
||||
group_id: UUID,
|
||||
) -> ResponseBase:
|
||||
) -> GroupDetailResponse:
|
||||
"""
|
||||
根据用户组ID获取用户组详细信息。
|
||||
|
||||
@@ -71,9 +71,7 @@ async def router_admin_get_group(
|
||||
# 直接访问已加载的关系,无需额外查询
|
||||
policies = group.policies
|
||||
user_count = await User.count(session, User.group_id == group_id)
|
||||
response = GroupDetailResponse.from_group(group, user_count, policies)
|
||||
|
||||
return ResponseBase(data=response.model_dump())
|
||||
return GroupDetailResponse.from_group(group, user_count, policies)
|
||||
|
||||
|
||||
@admin_group_router.get(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -8,7 +9,8 @@ from middleware.auth import admin_required
|
||||
from middleware.dependencies import SessionDep, TableViewRequestDep
|
||||
from sqlmodels import (
|
||||
Policy, PolicyBase, PolicyType, PolicySummary, ResponseBase,
|
||||
ListResponse, Object, )
|
||||
ListResponse, Object,
|
||||
)
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
from service.storage import DirectoryCreationError, LocalStorageService
|
||||
|
||||
@@ -17,6 +19,78 @@ admin_policy_router = APIRouter(
|
||||
tags=['admin', 'admin_policy']
|
||||
)
|
||||
|
||||
|
||||
class PathTestResponse(SQLModelBase):
|
||||
"""路径测试响应"""
|
||||
|
||||
path: str
|
||||
"""解析后的路径"""
|
||||
|
||||
is_exists: bool
|
||||
"""路径是否存在"""
|
||||
|
||||
is_writable: bool
|
||||
"""路径是否可写"""
|
||||
|
||||
|
||||
class PolicyGroupInfo(SQLModelBase):
|
||||
"""策略关联的用户组信息"""
|
||||
|
||||
id: str
|
||||
"""用户组UUID"""
|
||||
|
||||
name: str
|
||||
"""用户组名称"""
|
||||
|
||||
|
||||
class PolicyDetailResponse(SQLModelBase):
|
||||
"""存储策略详情响应"""
|
||||
|
||||
id: str
|
||||
"""策略UUID"""
|
||||
|
||||
name: str
|
||||
"""策略名称"""
|
||||
|
||||
type: str
|
||||
"""策略类型"""
|
||||
|
||||
server: str | None
|
||||
"""服务器地址"""
|
||||
|
||||
bucket_name: str | None
|
||||
"""存储桶名称"""
|
||||
|
||||
is_private: bool
|
||||
"""是否私有"""
|
||||
|
||||
base_url: str | None
|
||||
"""基础URL"""
|
||||
|
||||
max_size: int
|
||||
"""最大文件尺寸"""
|
||||
|
||||
auto_rename: bool
|
||||
"""是否自动重命名"""
|
||||
|
||||
dir_name_rule: str | None
|
||||
"""目录命名规则"""
|
||||
|
||||
file_name_rule: str | None
|
||||
"""文件命名规则"""
|
||||
|
||||
is_origin_link_enable: bool
|
||||
"""是否启用外链"""
|
||||
|
||||
options: dict[str, Any] | None
|
||||
"""策略选项"""
|
||||
|
||||
groups: list[PolicyGroupInfo]
|
||||
"""关联的用户组"""
|
||||
|
||||
object_count: int
|
||||
"""使用此策略的对象数量"""
|
||||
|
||||
class PolicyTestPathRequest(SQLModelBase):
|
||||
"""测试本地路径请求 DTO"""
|
||||
|
||||
@@ -70,7 +144,7 @@ async def router_policy_list(
|
||||
)
|
||||
async def router_policy_test_path(
|
||||
request: PolicyTestPathRequest,
|
||||
) -> ResponseBase:
|
||||
) -> PathTestResponse:
|
||||
"""
|
||||
测试本地存储路径是否可用。
|
||||
|
||||
@@ -97,22 +171,23 @@ async def router_policy_test_path(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return ResponseBase(data={
|
||||
"path": str(path),
|
||||
"exists": is_exists,
|
||||
"writable": is_writable,
|
||||
})
|
||||
return PathTestResponse(
|
||||
path=str(path),
|
||||
is_exists=is_exists,
|
||||
is_writable=is_writable,
|
||||
)
|
||||
|
||||
|
||||
@admin_policy_router.post(
|
||||
path='/test/slave',
|
||||
summary='测试从机通信',
|
||||
description='Test slave node communication',
|
||||
dependencies=[Depends(admin_required)]
|
||||
dependencies=[Depends(admin_required)],
|
||||
status_code=204,
|
||||
)
|
||||
async def router_policy_test_slave(
|
||||
request: PolicyTestSlaveRequest,
|
||||
) -> ResponseBase:
|
||||
) -> None:
|
||||
"""
|
||||
测试从机RPC通信。
|
||||
|
||||
@@ -129,25 +204,28 @@ async def router_policy_test_slave(
|
||||
timeout=aiohttp.ClientTimeout(total=10)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
return ResponseBase(data={"connected": True})
|
||||
return
|
||||
else:
|
||||
return ResponseBase(
|
||||
code=400,
|
||||
msg=f"从机响应错误,HTTP {resp.status}"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"从机响应错误,HTTP {resp.status}",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
return ResponseBase(code=400, msg=f"连接失败: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=f"连接失败: {str(e)}")
|
||||
|
||||
@admin_policy_router.post(
|
||||
path='/',
|
||||
summary='创建存储策略',
|
||||
description='创建新的存储策略。对于本地存储策略,会自动创建物理目录。',
|
||||
dependencies=[Depends(admin_required)]
|
||||
dependencies=[Depends(admin_required)],
|
||||
status_code=204,
|
||||
)
|
||||
async def router_policy_add_policy(
|
||||
session: SessionDep,
|
||||
request: PolicyCreateRequest,
|
||||
) -> ResponseBase:
|
||||
) -> None:
|
||||
"""
|
||||
创建存储策略端点
|
||||
|
||||
@@ -199,14 +277,7 @@ async def router_policy_add_policy(
|
||||
raise HTTPException(status_code=500, detail=f"创建存储目录失败: {e}")
|
||||
|
||||
# 保存到数据库
|
||||
policy = await policy.save(session)
|
||||
|
||||
return ResponseBase(data={
|
||||
"id": str(policy.id),
|
||||
"name": policy.name,
|
||||
"type": policy.type.value,
|
||||
"server": policy.server,
|
||||
})
|
||||
await policy.save(session)
|
||||
|
||||
@admin_policy_router.post(
|
||||
path='/cors',
|
||||
@@ -274,7 +345,7 @@ async def router_policy_onddrive_oauth(
|
||||
async def router_policy_get_policy(
|
||||
session: SessionDep,
|
||||
policy_id: UUID,
|
||||
) -> ResponseBase:
|
||||
) -> PolicyDetailResponse:
|
||||
"""
|
||||
获取存储策略详情。
|
||||
|
||||
@@ -292,35 +363,36 @@ async def router_policy_get_policy(
|
||||
# 统计使用此策略的对象数量
|
||||
object_count = await Object.count(session, Object.policy_id == policy_id)
|
||||
|
||||
return ResponseBase(data={
|
||||
"id": str(policy.id),
|
||||
"name": policy.name,
|
||||
"type": policy.type.value,
|
||||
"server": policy.server,
|
||||
"bucket_name": policy.bucket_name,
|
||||
"is_private": policy.is_private,
|
||||
"base_url": policy.base_url,
|
||||
"max_size": policy.max_size,
|
||||
"auto_rename": policy.auto_rename,
|
||||
"dir_name_rule": policy.dir_name_rule,
|
||||
"file_name_rule": policy.file_name_rule,
|
||||
"is_origin_link_enable": policy.is_origin_link_enable,
|
||||
"options": policy.options.model_dump() if policy.options else None,
|
||||
"groups": [{"id": str(g.id), "name": g.name} for g in groups],
|
||||
"object_count": object_count,
|
||||
})
|
||||
return PolicyDetailResponse(
|
||||
id=str(policy.id),
|
||||
name=policy.name,
|
||||
type=policy.type.value,
|
||||
server=policy.server,
|
||||
bucket_name=policy.bucket_name,
|
||||
is_private=policy.is_private,
|
||||
base_url=policy.base_url,
|
||||
max_size=policy.max_size,
|
||||
auto_rename=policy.auto_rename,
|
||||
dir_name_rule=policy.dir_name_rule,
|
||||
file_name_rule=policy.file_name_rule,
|
||||
is_origin_link_enable=policy.is_origin_link_enable,
|
||||
options=policy.options.model_dump() if policy.options else None,
|
||||
groups=[PolicyGroupInfo(id=str(g.id), name=g.name) for g in groups],
|
||||
object_count=object_count,
|
||||
)
|
||||
|
||||
|
||||
@admin_policy_router.delete(
|
||||
path='/{policy_id}',
|
||||
summary='删除存储策略',
|
||||
description='Delete storage policy by ID',
|
||||
dependencies=[Depends(admin_required)]
|
||||
dependencies=[Depends(admin_required)],
|
||||
status_code=204,
|
||||
)
|
||||
async def router_policy_delete_policy(
|
||||
session: SessionDep,
|
||||
policy_id: UUID,
|
||||
) -> ResponseBase:
|
||||
) -> None:
|
||||
"""
|
||||
删除存储策略。
|
||||
|
||||
@@ -345,5 +417,4 @@ async def router_policy_delete_policy(
|
||||
policy_name = policy.name
|
||||
await Policy.delete(session, policy)
|
||||
|
||||
l.info(f"管理员删除了存储策略: {policy_name}")
|
||||
return ResponseBase(data={"deleted": True})
|
||||
l.info(f"管理员删除了存储策略: {policy_name}")
|
||||
@@ -1,3 +1,4 @@
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -6,8 +7,53 @@ from loguru import logger as l
|
||||
from middleware.auth import admin_required
|
||||
from middleware.dependencies import SessionDep, TableViewRequestDep
|
||||
from sqlmodels import (
|
||||
ResponseBase, ListResponse,
|
||||
Share, AdminShareListItem, )
|
||||
ListResponse,
|
||||
Share, AdminShareListItem,
|
||||
)
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
|
||||
|
||||
class ShareDetailResponse(SQLModelBase):
|
||||
"""分享详情响应"""
|
||||
|
||||
id: UUID
|
||||
"""分享UUID"""
|
||||
|
||||
code: str
|
||||
"""分享码"""
|
||||
|
||||
views: int
|
||||
"""浏览次数"""
|
||||
|
||||
downloads: int
|
||||
"""下载次数"""
|
||||
|
||||
remain_downloads: int | None
|
||||
"""剩余下载次数"""
|
||||
|
||||
expires: datetime | None
|
||||
"""过期时间"""
|
||||
|
||||
preview_enabled: bool
|
||||
"""是否启用预览"""
|
||||
|
||||
score: int
|
||||
"""评分"""
|
||||
|
||||
has_password: bool
|
||||
"""是否有密码"""
|
||||
|
||||
user_id: str
|
||||
"""用户UUID"""
|
||||
|
||||
username: str | None
|
||||
"""用户名"""
|
||||
|
||||
object: dict | None
|
||||
"""关联对象信息"""
|
||||
|
||||
created_at: str
|
||||
"""创建时间"""
|
||||
|
||||
admin_share_router = APIRouter(
|
||||
prefix='/share',
|
||||
@@ -54,7 +100,7 @@ async def router_admin_get_share_list(
|
||||
async def router_admin_get_share(
|
||||
session: SessionDep,
|
||||
share_id: UUID,
|
||||
) -> ResponseBase:
|
||||
) -> ShareDetailResponse:
|
||||
"""
|
||||
获取分享详情。
|
||||
|
||||
@@ -69,38 +115,39 @@ async def router_admin_get_share(
|
||||
obj = await share.awaitable_attrs.object
|
||||
user = await share.awaitable_attrs.user
|
||||
|
||||
return ResponseBase(data={
|
||||
"id": share.id,
|
||||
"code": share.code,
|
||||
"views": share.views,
|
||||
"downloads": share.downloads,
|
||||
"remain_downloads": share.remain_downloads,
|
||||
"expires": share.expires.isoformat() if share.expires else None,
|
||||
"preview_enabled": share.preview_enabled,
|
||||
"score": share.score,
|
||||
"has_password": bool(share.password),
|
||||
"user_id": str(share.user_id),
|
||||
"username": user.email if user else None,
|
||||
"object": {
|
||||
return ShareDetailResponse(
|
||||
id=share.id,
|
||||
code=share.code,
|
||||
views=share.views,
|
||||
downloads=share.downloads,
|
||||
remain_downloads=share.remain_downloads,
|
||||
expires=share.expires,
|
||||
preview_enabled=share.preview_enabled,
|
||||
score=share.score,
|
||||
has_password=bool(share.password),
|
||||
user_id=str(share.user_id),
|
||||
username=user.email if user else None,
|
||||
object={
|
||||
"id": str(obj.id),
|
||||
"name": obj.name,
|
||||
"type": obj.type.value,
|
||||
"size": obj.size,
|
||||
} if obj else None,
|
||||
"created_at": share.created_at.isoformat(),
|
||||
})
|
||||
created_at=share.created_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@admin_share_router.delete(
|
||||
path='/{share_id}',
|
||||
summary='删除分享',
|
||||
description='Delete share by ID',
|
||||
dependencies=[Depends(admin_required)]
|
||||
dependencies=[Depends(admin_required)],
|
||||
status_code=204,
|
||||
)
|
||||
async def router_admin_delete_share(
|
||||
session: SessionDep,
|
||||
share_id: UUID,
|
||||
) -> ResponseBase:
|
||||
) -> None:
|
||||
"""
|
||||
删除分享。
|
||||
|
||||
@@ -114,5 +161,4 @@ async def router_admin_delete_share(
|
||||
|
||||
await Share.delete(session, share)
|
||||
|
||||
l.info(f"管理员删除了分享: {share.code}")
|
||||
return ResponseBase(data={"deleted": True})
|
||||
l.info(f"管理员删除了分享: {share.code}")
|
||||
@@ -1,3 +1,4 @@
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
@@ -6,9 +7,44 @@ from loguru import logger as l
|
||||
from middleware.auth import admin_required
|
||||
from middleware.dependencies import SessionDep, TableViewRequestDep
|
||||
from sqlmodels import (
|
||||
ResponseBase, ListResponse,
|
||||
ListResponse,
|
||||
Task, TaskSummary,
|
||||
)
|
||||
from sqlmodel_ext import SQLModelBase
|
||||
|
||||
|
||||
class TaskDetailResponse(SQLModelBase):
|
||||
"""任务详情响应"""
|
||||
|
||||
id: int
|
||||
"""任务ID"""
|
||||
|
||||
status: int
|
||||
"""任务状态"""
|
||||
|
||||
type: int
|
||||
"""任务类型"""
|
||||
|
||||
progress: int
|
||||
"""任务进度"""
|
||||
|
||||
error: str | None
|
||||
"""错误信息"""
|
||||
|
||||
user_id: str
|
||||
"""用户UUID"""
|
||||
|
||||
username: str | None
|
||||
"""用户名"""
|
||||
|
||||
props: dict[str, Any] | None
|
||||
"""任务属性"""
|
||||
|
||||
created_at: str
|
||||
"""创建时间"""
|
||||
|
||||
updated_at: str
|
||||
"""更新时间"""
|
||||
|
||||
admin_task_router = APIRouter(
|
||||
prefix='/task',
|
||||
@@ -67,7 +103,7 @@ async def router_admin_get_task_list(
|
||||
async def router_admin_get_task(
|
||||
session: SessionDep,
|
||||
task_id: int,
|
||||
) -> ResponseBase:
|
||||
) -> TaskDetailResponse:
|
||||
"""
|
||||
获取任务详情。
|
||||
|
||||
@@ -82,30 +118,31 @@ async def router_admin_get_task(
|
||||
user = await task.awaitable_attrs.user
|
||||
props = await task.awaitable_attrs.props
|
||||
|
||||
return ResponseBase(data={
|
||||
"id": task.id,
|
||||
"status": task.status,
|
||||
"type": task.type,
|
||||
"progress": task.progress,
|
||||
"error": task.error,
|
||||
"user_id": str(task.user_id),
|
||||
"username": user.email if user else None,
|
||||
"props": props.model_dump() if props else None,
|
||||
"created_at": task.created_at.isoformat(),
|
||||
"updated_at": task.updated_at.isoformat(),
|
||||
})
|
||||
return TaskDetailResponse(
|
||||
id=task.id,
|
||||
status=task.status,
|
||||
type=task.type,
|
||||
progress=task.progress,
|
||||
error=task.error,
|
||||
user_id=str(task.user_id),
|
||||
username=user.email if user else None,
|
||||
props=props.model_dump() if props else None,
|
||||
created_at=task.created_at.isoformat(),
|
||||
updated_at=task.updated_at.isoformat(),
|
||||
)
|
||||
|
||||
|
||||
@admin_task_router.delete(
|
||||
path='/{task_id}',
|
||||
summary='删除任务',
|
||||
description='Delete task by ID',
|
||||
dependencies=[Depends(admin_required)]
|
||||
dependencies=[Depends(admin_required)],
|
||||
status_code=204,
|
||||
)
|
||||
async def router_admin_delete_task(
|
||||
session: SessionDep,
|
||||
task_id: int,
|
||||
) -> ResponseBase:
|
||||
) -> None:
|
||||
"""
|
||||
删除任务。
|
||||
|
||||
@@ -119,5 +156,4 @@ async def router_admin_delete_task(
|
||||
|
||||
await Task.delete(session, task)
|
||||
|
||||
l.info(f"管理员删除了任务: {task_id}")
|
||||
return ResponseBase(data={"deleted": True})
|
||||
l.info(f"管理员删除了任务: {task_id}")
|
||||
Reference in New Issue
Block a user