- Add ChromaticColor (17 Tailwind colors) and NeutralColor (5 grays) enums - Add ThemePreset table with flat color columns and unique name constraint - Add admin theme endpoints (CRUD + set default) at /api/v1/admin/theme - Add public theme listing at /api/v1/site/themes - Add user theme settings (PATCH /theme) with color snapshot on User model - User.color_* columns store per-user overrides; fallback to default preset then builtin - Initialize default theme preset in migration - Remove legacy defaultTheme/themes settings Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
489 lines
14 KiB
Python
489 lines
14 KiB
Python
from typing import Annotated, Literal
|
||
from uuid import UUID, uuid4
|
||
from datetime import datetime
|
||
|
||
from fastapi import APIRouter, Depends, Query, HTTPException
|
||
from loguru import logger as l
|
||
|
||
from middleware.auth import auth_required
|
||
from middleware.dependencies import SessionDep
|
||
from sqlmodels import ResponseBase
|
||
from sqlmodels.user import User
|
||
from sqlmodels.share import (
|
||
Share, ShareCreateRequest, CreateShareResponse, ShareResponse,
|
||
ShareDetailResponse, ShareOwnerInfo, ShareObjectItem,
|
||
)
|
||
from sqlmodels.object import Object, ObjectType
|
||
from sqlmodels.mixin import ListResponse, TableViewRequest
|
||
from utils import http_exceptions
|
||
from utils.password.pwd import Password, PasswordStatus
|
||
|
||
share_router = APIRouter(
|
||
prefix='/share',
|
||
tags=["share"],
|
||
)
|
||
|
||
@share_router.get(
|
||
path='/{id}',
|
||
summary='获取分享详情',
|
||
description='Get share detail by share ID. No authentication required.',
|
||
)
|
||
async def router_share_get(
|
||
session: SessionDep,
|
||
id: UUID,
|
||
password: str | None = Query(default=None),
|
||
) -> ShareDetailResponse:
|
||
"""
|
||
获取分享详情
|
||
|
||
认证:无需登录
|
||
|
||
流程:
|
||
1. 通过分享ID查找分享
|
||
2. 检查过期、封禁状态
|
||
3. 验证提取码(如果有)
|
||
4. 返回分享详情(含文件树和分享者信息)
|
||
"""
|
||
# 1. 查询分享(预加载 user 和 object)
|
||
share = await Share.get(
|
||
session, Share.id == id,
|
||
load=[Share.user, Share.object],
|
||
)
|
||
if not share:
|
||
http_exceptions.raise_not_found(detail="分享不存在或已被取消")
|
||
|
||
# 2. 检查过期
|
||
now = datetime.now()
|
||
if share.expires and share.expires < now:
|
||
http_exceptions.raise_not_found(detail="分享已过期")
|
||
|
||
# 3. 获取关联对象
|
||
obj = await share.awaitable_attrs.object
|
||
user = await share.awaitable_attrs.user
|
||
|
||
# 4. 检查封禁和软删除
|
||
if obj and obj.is_banned:
|
||
http_exceptions.raise_banned()
|
||
if obj and obj.deleted_at:
|
||
http_exceptions.raise_not_found(detail="分享关联的文件已被删除")
|
||
|
||
# 5. 检查密码
|
||
if share.password:
|
||
if not password:
|
||
http_exceptions.raise_precondition_required(detail="请输入提取码")
|
||
if Password.verify(share.password, password) != PasswordStatus.VALID:
|
||
http_exceptions.raise_forbidden(detail="提取码错误")
|
||
|
||
# 6. 加载子对象(目录分享)
|
||
children_items: list[ShareObjectItem] = []
|
||
if obj and obj.type == ObjectType.FOLDER:
|
||
children = await Object.get_children(session, obj.owner_id, obj.id)
|
||
children_items = [
|
||
ShareObjectItem(
|
||
id=child.id,
|
||
name=child.name,
|
||
type=child.type,
|
||
size=child.size,
|
||
created_at=child.created_at,
|
||
updated_at=child.updated_at,
|
||
)
|
||
for child in children
|
||
]
|
||
|
||
# 7. 构建响应(在 save 之前,避免 MissingGreenlet)
|
||
response = ShareDetailResponse(
|
||
expires=share.expires,
|
||
preview_enabled=share.preview_enabled,
|
||
score=share.score,
|
||
created_at=share.created_at,
|
||
owner=ShareOwnerInfo(
|
||
nickname=user.nickname if user else None,
|
||
avatar=user.avatar if user else "default",
|
||
),
|
||
object=ShareObjectItem(
|
||
id=obj.id,
|
||
name=obj.name,
|
||
type=obj.type,
|
||
size=obj.size,
|
||
created_at=obj.created_at,
|
||
updated_at=obj.updated_at,
|
||
),
|
||
children=children_items,
|
||
)
|
||
|
||
# 8. 递增浏览次数(最后执行,避免 MissingGreenlet)
|
||
share.views += 1
|
||
await share.save(session, refresh=False)
|
||
|
||
return response
|
||
|
||
@share_router.put(
|
||
path='/download/{id}',
|
||
summary='创建文件下载会话',
|
||
description='Create a file download session by ID.',
|
||
)
|
||
def router_share_download(id: str) -> ResponseBase:
|
||
"""
|
||
Create a file download session by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the file to be downloaded.
|
||
|
||
Returns:
|
||
dict: A dictionary containing download session information.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='preview/{id}',
|
||
summary='预览分享文件',
|
||
description='Preview shared file by ID.',
|
||
)
|
||
def router_share_preview(id: str) -> ResponseBase:
|
||
"""
|
||
Preview shared file by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the file to be previewed.
|
||
|
||
Returns:
|
||
dict: A dictionary containing preview information.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='/content/{id}',
|
||
summary='获取文本文件内容',
|
||
description='Get text file content by ID.',
|
||
)
|
||
def router_share_content(id: str) -> ResponseBase:
|
||
"""
|
||
Get text file content by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the text file.
|
||
|
||
Returns:
|
||
str: The content of the text file.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='/list/{id}/{path:path}',
|
||
summary='获取目录列文件',
|
||
description='Get directory listing by ID and path.',
|
||
)
|
||
def router_share_list(id: str, path: str = '') -> ResponseBase:
|
||
"""
|
||
Get directory listing by ID and path.
|
||
|
||
Args:
|
||
id (str): The ID of the directory.
|
||
path (str): The path within the directory.
|
||
|
||
Returns:
|
||
dict: A dictionary containing directory listing information.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='/search/{id}/{type}/{keywords}',
|
||
summary='分享目录搜索',
|
||
description='Search within a shared directory by ID, type, and keywords.',
|
||
)
|
||
def router_share_search(id: str, type: str, keywords: str) -> ResponseBase:
|
||
"""
|
||
Search within a shared directory by ID, type, and keywords.
|
||
|
||
Args:
|
||
id (str): The ID of the shared directory.
|
||
type (str): The type of search (e.g., file, folder).
|
||
keywords (str): The keywords to search for.
|
||
|
||
Returns:
|
||
dict: A dictionary containing search results.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.post(
|
||
path='/archive/{id}',
|
||
summary='归档打包下载',
|
||
description='Archive and download shared content by ID.',
|
||
)
|
||
def router_share_archive(id: str) -> ResponseBase:
|
||
"""
|
||
Archive and download shared content by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the content to be archived.
|
||
|
||
Returns:
|
||
dict: A dictionary containing archive download information.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='/readme/{id}',
|
||
summary='获取README文本文件内容',
|
||
description='Get README text file content by ID.',
|
||
)
|
||
def router_share_readme(id: str) -> ResponseBase:
|
||
"""
|
||
Get README text file content by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the README file.
|
||
|
||
Returns:
|
||
str: The content of the README file.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='/thumb/{id}/{file}',
|
||
summary='获取缩略图',
|
||
description='Get thumbnail image by ID and file name.',
|
||
)
|
||
def router_share_thumb(id: str, file: str) -> ResponseBase:
|
||
"""
|
||
Get thumbnail image by ID and file name.
|
||
|
||
Args:
|
||
id (str): The ID of the shared content.
|
||
file (str): The name of the file for which to get the thumbnail.
|
||
|
||
Returns:
|
||
str: A Base64 encoded string of the thumbnail image.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.post(
|
||
path='/report/{id}',
|
||
summary='举报分享',
|
||
description='Report shared content by ID.',
|
||
)
|
||
def router_share_report(id: str) -> ResponseBase:
|
||
"""
|
||
Report shared content by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the shared content to report.
|
||
|
||
Returns:
|
||
dict: A dictionary containing report submission information.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.get(
|
||
path='/search',
|
||
summary='搜索公共分享',
|
||
description='Search public shares by keywords and type.',
|
||
)
|
||
def router_share_search_public(keywords: str, type: str = 'all') -> ResponseBase:
|
||
"""
|
||
Search public shares by keywords and type.
|
||
|
||
Args:
|
||
keywords (str): The keywords to search for.
|
||
type (str): The type of search (e.g., all, file, folder).
|
||
|
||
Returns:
|
||
dict: A dictionary containing search results for public shares.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
#####################
|
||
# 需要登录的接口
|
||
#####################
|
||
|
||
@share_router.post(
|
||
path='/',
|
||
summary='创建新分享',
|
||
description='Create a new share endpoint.',
|
||
)
|
||
async def router_share_create(
|
||
session: SessionDep,
|
||
user: Annotated[User, Depends(auth_required)],
|
||
request: ShareCreateRequest,
|
||
) -> CreateShareResponse:
|
||
"""
|
||
创建新分享
|
||
|
||
认证:需要 JWT token
|
||
|
||
流程:
|
||
1. 验证对象存在且属于当前用户
|
||
2. 生成随机分享码(uuid4)
|
||
3. 如果有密码则加密存储
|
||
4. 创建 Share 记录并保存
|
||
5. 返回分享 ID
|
||
"""
|
||
# 验证对象存在且属于当前用户(排除已删除的)
|
||
obj = await Object.get(
|
||
session,
|
||
(Object.id == request.object_id) & (Object.deleted_at == None)
|
||
)
|
||
if not obj or obj.owner_id != user.id:
|
||
raise HTTPException(status_code=404, detail="对象不存在或无权限")
|
||
|
||
if obj.is_banned:
|
||
http_exceptions.raise_banned()
|
||
|
||
# 生成分享码
|
||
code = str(uuid4())
|
||
|
||
# 密码加密处理(如果有)
|
||
hashed_password = None
|
||
if request.password:
|
||
hashed_password = Password.hash(request.password)
|
||
|
||
# 创建分享记录
|
||
user_id = user.id
|
||
share = Share(
|
||
code=code,
|
||
password=hashed_password,
|
||
object_id=request.object_id,
|
||
user_id=user_id,
|
||
expires=request.expires,
|
||
remain_downloads=request.remain_downloads,
|
||
preview_enabled=request.preview_enabled,
|
||
score=request.score,
|
||
source_name=obj.name,
|
||
)
|
||
share = await share.save(session)
|
||
|
||
l.info(f"用户 {user_id} 创建分享: {share.code}")
|
||
|
||
return CreateShareResponse(share_id=share.id)
|
||
|
||
@share_router.get(
|
||
path='/',
|
||
summary='列出我的分享',
|
||
description='Get a list of shares.',
|
||
)
|
||
async def router_share_list(
|
||
session: SessionDep,
|
||
user: Annotated[User, Depends(auth_required)],
|
||
offset: int = Query(default=0, ge=0),
|
||
limit: int = Query(default=50, le=100),
|
||
desc: bool = Query(default=True),
|
||
order: Literal["created_at", "updated_at"] = Query(default="created_at"),
|
||
keyword: str | None = Query(default=None),
|
||
expired: bool | None = Query(default=None),
|
||
) -> ListResponse[ShareResponse]:
|
||
"""
|
||
列出我的分享
|
||
|
||
认证:需要 JWT token
|
||
|
||
支持:
|
||
- 分页和排序
|
||
- 关键字搜索(搜索 source_name)
|
||
- 过期状态筛选
|
||
"""
|
||
# 构建基础条件
|
||
condition = Share.user_id == user.id
|
||
|
||
# 关键字搜索
|
||
if keyword:
|
||
condition = condition & Share.source_name.ilike(f"%{keyword}%")
|
||
|
||
# 过期状态筛选
|
||
now = datetime.now()
|
||
if expired is True:
|
||
# 已过期:expires 不为 NULL 且 < 当前时间
|
||
condition = condition & (Share.expires != None) & (Share.expires < now)
|
||
elif expired is False:
|
||
# 未过期:expires 为 NULL 或 >= 当前时间
|
||
condition = condition & ((Share.expires == None) | (Share.expires >= now))
|
||
|
||
# 构建 table_view
|
||
table_view = TableViewRequest(
|
||
offset=offset,
|
||
limit=limit,
|
||
desc=desc,
|
||
order=order,
|
||
)
|
||
|
||
# 使用 get_with_count 获取分页数据
|
||
result = await Share.get_with_count(
|
||
session,
|
||
condition,
|
||
table_view=table_view,
|
||
)
|
||
|
||
# 转换为响应模型
|
||
items = [
|
||
ShareResponse(
|
||
id=share.id,
|
||
code=share.code,
|
||
object_id=share.object_id,
|
||
source_name=share.source_name,
|
||
views=share.views,
|
||
downloads=share.downloads,
|
||
remain_downloads=share.remain_downloads,
|
||
expires=share.expires,
|
||
preview_enabled=share.preview_enabled,
|
||
score=share.score,
|
||
created_at=share.created_at,
|
||
is_expired=share.expires is not None and share.expires < now,
|
||
has_password=share.password is not None,
|
||
)
|
||
for share in result.items
|
||
]
|
||
|
||
return ListResponse(count=result.count, items=items)
|
||
|
||
@share_router.post(
|
||
path='/save/{id}',
|
||
summary='转存他人分享',
|
||
description='Save another user\'s share by ID.',
|
||
dependencies=[Depends(auth_required)]
|
||
)
|
||
def router_share_save(id: str) -> ResponseBase:
|
||
"""
|
||
Save another user's share by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the share to be saved.
|
||
|
||
Returns:
|
||
ResponseBase: A model containing the response data for the saved share.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.patch(
|
||
path='/{id}',
|
||
summary='更新分享信息',
|
||
description='Update share information by ID.',
|
||
dependencies=[Depends(auth_required)]
|
||
)
|
||
def router_share_update(id: str) -> ResponseBase:
|
||
"""
|
||
Update share information by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the share to be updated.
|
||
|
||
Returns:
|
||
ResponseBase: A model containing the response data for the updated share.
|
||
"""
|
||
http_exceptions.raise_not_implemented()
|
||
|
||
@share_router.delete(
|
||
path='/{id}',
|
||
summary='删除分享',
|
||
description='Delete a share by ID.',
|
||
dependencies=[Depends(auth_required)]
|
||
)
|
||
def router_share_delete(id: str) -> ResponseBase:
|
||
"""
|
||
Delete a share by ID.
|
||
|
||
Args:
|
||
id (str): The ID of the share to be deleted.
|
||
|
||
Returns:
|
||
ResponseBase: A model containing the response data for the deleted share.
|
||
"""
|
||
http_exceptions.raise_not_implemented() |