Files
disknext/routers/api/v1/admin/file/__init__.py
于小丘 a99091ea7a feat: embed permission claims in JWT and add captcha verification
- Add GroupClaims model for JWT permission snapshots
- Add JWTPayload model for typed JWT decoding
- Refactor auth middleware: jwt_required (no DB) -> admin_required (no DB) -> auth_required (DB)
- Add UserBanStore for instant ban enforcement via Redis + memory fallback
- Fix status check bug: StrEnum is always truthy, use explicit != ACTIVE
- Shorten access_token expiry from 3h to 1h
- Add CaptchaScene enum and verify_captcha_if_needed service
- Add require_captcha dependency injection factory
- Add CLA document and new default settings
- Update all tests for new JWT API

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-10 19:07:48 +08:00

248 lines
7.7 KiB
Python

from datetime import datetime
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import FileResponse
from loguru import logger as l
from sqlmodel.ext.asyncio.session import AsyncSession
from middleware.auth import admin_required
from middleware.dependencies import SessionDep, TableViewRequestDep
from sqlmodels import (
JWTPayload, Policy, PolicyType, User, ListResponse,
Object, ObjectType, AdminFileResponse, FileBanRequest, )
from service.storage import LocalStorageService
async def _set_ban_recursive(
session: AsyncSession,
obj: Object,
ban: bool,
admin_id: UUID,
reason: str | None,
) -> int:
"""
递归设置封禁状态,返回受影响对象数量。
:param session: 数据库会话
:param obj: 要封禁/解禁的对象
:param ban: True=封禁, False=解禁
:param admin_id: 管理员UUID
:param reason: 封禁原因
:return: 受影响的对象数量
"""
count = 0
# 如果是文件夹,先递归处理子对象
if obj.is_folder:
children = await Object.get(
session,
Object.parent_id == obj.id,
fetch_mode="all",
)
for child in children:
count += await _set_ban_recursive(session, child, ban, admin_id, reason)
# 设置当前对象
obj.is_banned = ban
if ban:
obj.banned_at = datetime.now()
obj.banned_by = admin_id
obj.ban_reason = reason
else:
obj.banned_at = None
obj.banned_by = None
obj.ban_reason = None
await obj.save(session)
count += 1
return count
admin_file_router = APIRouter(
prefix="/file",
tags=["admin", "admin_file"],
)
@admin_file_router.get(
path='/list',
summary='获取文件列表',
description='Get file list',
dependencies=[Depends(admin_required)],
)
async def router_admin_get_file_list(
session: SessionDep,
table_view: TableViewRequestDep,
user_id: UUID | None = None,
is_banned: bool | None = None,
keyword: str | None = None,
) -> ListResponse[AdminFileResponse]:
"""
获取系统中的文件列表,支持筛选。
:param session: 数据库会话
:param table_view: 分页排序参数依赖
:param user_id: 按用户筛选
:param is_banned: 按封禁状态筛选
:param keyword: 按文件名搜索
:return: 分页文件列表
"""
# 构建查询条件
conditions = [Object.type == ObjectType.FILE]
if user_id:
conditions.append(Object.owner_id == user_id)
if is_banned is not None:
conditions.append(Object.is_banned == is_banned)
if keyword:
conditions.append(Object.name.ilike(f"%{keyword}%"))
if len(conditions) > 1:
condition = conditions[0]
for c in conditions[1:]:
condition = condition & c
else:
condition = conditions[0]
result = await Object.get_with_count(session, condition, table_view=table_view, load=Object.owner)
# 构建响应
items: list[AdminFileResponse] = []
for f in result.items:
owner = await f.awaitable_attrs.owner
policy = await f.awaitable_attrs.policy
items.append(AdminFileResponse.from_object(f, owner, policy))
return ListResponse(items=items, count=result.count)
@admin_file_router.get(
path='/preview/{file_id}',
summary='预览文件',
description='Preview file by ID',
dependencies=[Depends(admin_required)],
)
async def router_admin_preview_file(
session: SessionDep,
file_id: UUID,
) -> FileResponse:
"""
管理员预览文件内容。
:param session: 数据库会话
:param file_id: 文件UUID
:return: 文件内容
"""
file_obj = await Object.get(session, Object.id == file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
if not file_obj.is_file:
raise HTTPException(status_code=400, detail="对象不是文件")
# 获取物理文件
physical_file = await file_obj.awaitable_attrs.physical_file
if not physical_file or not physical_file.storage_path:
raise HTTPException(status_code=500, detail="文件存储路径丢失")
policy = await Policy.get(session, Policy.id == file_obj.policy_id)
if not policy:
raise HTTPException(status_code=500, detail="存储策略不存在")
if policy.type == PolicyType.LOCAL:
storage_service = LocalStorageService(policy)
if not await storage_service.file_exists(physical_file.storage_path):
raise HTTPException(status_code=404, detail="物理文件不存在")
return FileResponse(
path=physical_file.storage_path,
filename=file_obj.name,
)
else:
raise HTTPException(status_code=501, detail="S3 存储暂未实现")
@admin_file_router.patch(
path='/ban/{file_id}',
summary='封禁/解禁文件',
description='Ban the file, user can\'t open, copy, move, download or share this file if administrator ban.',
status_code=204,
)
async def router_admin_ban_file(
session: SessionDep,
file_id: UUID,
request: FileBanRequest,
claims: Annotated[JWTPayload, Depends(admin_required)],
) -> None:
"""
封禁或解禁文件/文件夹。封禁后用户无法访问该文件。
封禁文件夹时会级联封禁所有子对象。
:param session: 数据库会话
:param file_id: 文件UUID
:param request: 封禁请求
:param claims: 当前管理员 JWT claims
:return: 封禁结果
"""
file_obj = await Object.get(session, Object.id == file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
count = await _set_ban_recursive(session, file_obj, request.ban, claims.sub, request.reason)
action = "封禁" if request.ban else "解禁"
l.info(f"管理员{action}了对象: {file_obj.name},共影响 {count} 个对象")
@admin_file_router.delete(
path='/{file_id}',
summary='删除文件',
description='Delete file by ID',
dependencies=[Depends(admin_required)],
status_code=204,
)
async def router_admin_delete_file(
session: SessionDep,
file_id: UUID,
delete_physical: bool = True,
) -> None:
"""
删除文件。
:param session: 数据库会话
:param file_id: 文件UUID
:param delete_physical: 是否同时删除物理文件
:return: 删除结果
"""
file_obj = await Object.get(session, Object.id == file_id)
if not file_obj:
raise HTTPException(status_code=404, detail="文件不存在")
if not file_obj.is_file:
raise HTTPException(status_code=400, detail="对象不是文件")
file_name = file_obj.name
file_size = file_obj.size
owner_id = file_obj.owner_id
# 删除物理文件(可选)
if delete_physical:
physical_file = await file_obj.awaitable_attrs.physical_file
if physical_file and physical_file.storage_path:
policy = await Policy.get(session, Policy.id == file_obj.policy_id)
if policy and policy.type == PolicyType.LOCAL:
try:
storage_service = LocalStorageService(policy)
await storage_service.delete_file(physical_file.storage_path)
except Exception as e:
l.warning(f"删除物理文件失败: {e}")
# 更新用户存储量(使用 SQL UPDATE 直接更新,无需加载实例)
from sqlmodel import update as sql_update
stmt = sql_update(User).where(User.id == owner_id).values(
storage=max(0, User.storage - file_size)
)
await session.exec(stmt)
# 使用条件删除
await Object.delete(session, condition=Object.id == file_obj.id)
l.info(f"管理员删除了文件: {file_name}")