feat: implement source link endpoints and enforce policy rules
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:
2026-02-15 17:07:20 +08:00
parent 71883d32c0
commit 1ecc0fdc1c
11 changed files with 1051 additions and 150 deletions

View File

@@ -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}")