diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..af6ff71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ + +RUN uv sync --frozen --no-dev + +COPY . . + +EXPOSE 5213 + +CMD ["uv", "run", "fastapi", "run", "main.py", "--host", "0.0.0.0", "--port", "5213"] \ No newline at end of file diff --git a/main.py b/main.py index 3a5ed86..288014a 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,11 @@ from fastapi import FastAPI -from routers import routers + from pkg.conf import appmeta +from pkg.lifespan import lifespan from models.database import init_db from models.migration import migration -from pkg.lifespan import lifespan -from pkg.JWT import jwt as JWT +from pkg.JWT import JWT +from routers import routers # 添加初始化数据库启动项 lifespan.add_startup(init_db) @@ -32,6 +33,6 @@ if __name__ == "__main__": import uvicorn if appmeta.debug: - uvicorn.run(app='main:app', host=appmeta.host, port=appmeta.port, reload=True) + uvicorn.run(app='main:app', reload=True) else: - uvicorn.run(app=app, host=appmeta.host, port=appmeta.port) \ No newline at end of file + uvicorn.run(app=app) \ No newline at end of file diff --git a/middleware/auth.py b/middleware/auth.py index f602599..075da22 100644 --- a/middleware/auth.py +++ b/middleware/auth.py @@ -5,7 +5,7 @@ from jwt import InvalidTokenError import jwt from models.user import User -from pkg.JWT import jwt as JWT +from pkg.JWT import JWT from .dependencies import SessionDep credentials_exception = HTTPException( diff --git a/models/__init__.py b/models/__init__.py index abca687..125bb95 100644 --- a/models/__init__.py +++ b/models/__init__.py @@ -1,17 +1,35 @@ from . import response -from .user import User -from .user_authn import UserAuthn +from .user import ( + LoginRequest, + ThemeResponse, + TokenResponse, + User, + UserBase, + UserPublic, + UserResponse, + UserSettingResponse, + WebAuthnInfo, +) +from .user_authn import AuthnResponse, UserAuthn from .download import Download -from .object import Object, ObjectType -from .group import Group +from .group import Group, GroupBase, GroupOptionsBase, GroupResponse from .node import Node +from .object import ( + DirectoryCreateRequest, + DirectoryResponse, + Object, + ObjectBase, + ObjectResponse, + ObjectType, + PolicyResponse, +) from .order import Order from .policy import Policy from .redeem import Redeem from .report import Report -from .setting import Setting +from .setting import Setting, SettingsType, SiteConfigResponse from .share import Share from .source_link import SourceLink from .storage_pack import StoragePack diff --git a/models/base/table_base.py b/models/base/table_base.py index 5f717bb..2d1ee33 100644 --- a/models/base/table_base.py +++ b/models/base/table_base.py @@ -1,6 +1,6 @@ import uuid -from datetime import datetime, timezone -from typing import Union, List, TypeVar, Type, Literal, override, Optional, Any +from datetime import datetime +from typing import Union, List, TypeVar, Type, Literal, override, Optional from fastapi import HTTPException from sqlalchemy import DateTime, BinaryExpression, ClauseElement diff --git a/models/group.py b/models/group.py index 71ffb63..87e6cb1 100644 --- a/models/group.py +++ b/models/group.py @@ -1,13 +1,75 @@ from typing import TYPE_CHECKING + from sqlmodel import Field, Relationship, text -from .base import TableBase + +from .base import TableBase, SQLModelBase if TYPE_CHECKING: from .user import User -class GroupOptions(TableBase, table=True): +# ==================== Base 模型 ==================== + +class GroupBase(SQLModelBase): + """用户组基础字段,供数据库模型和 DTO 共享""" + + name: str + """用户组名称""" + + +class GroupOptionsBase(SQLModelBase): + """用户组选项基础字段,供数据库模型和 DTO 共享""" + + share_download: bool = False + """是否允许分享下载""" + + share_free: bool = False + """是否免积分分享""" + + relocate: bool = False + """是否允许文件重定位""" + + source_batch: int = 0 + """批量获取源地址数量""" + + select_node: bool = False + """是否允许选择节点""" + + advance_delete: bool = False + """是否允许高级删除""" + + +# ==================== DTO 模型 ==================== + +class GroupResponse(GroupBase, GroupOptionsBase): + """用户组响应 DTO""" + + id: int + """用户组ID""" + + allow_share: bool = False + """是否允许分享""" + + allow_remote_download: bool = False + """是否允许离线下载""" + + allow_archive_download: bool = False + """是否允许打包下载""" + + compress: bool = False + """是否允许压缩""" + + webdav: bool = False + """是否允许WebDAV""" + + allow_webdav_proxy: bool = False + """是否允许WebDAV代理""" + + +# ==================== 数据库模型 ==================== + +class GroupOptions(GroupOptionsBase, TableBase, table=True): """用户组选项模型""" group_id: int = Field(foreign_key="group.id", unique=True) @@ -19,41 +81,23 @@ class GroupOptions(TableBase, table=True): archive_task: bool = False """是否允许创建打包任务""" - share_download: bool = False - """是否允许分享下载""" - - share_free: bool = False - """是否免积分分享""" - webdav_proxy: bool = False """是否允许WebDAV代理""" aria2: bool = False """是否允许使用aria2""" - relocate: bool = False - """是否允许文件重定位""" - - source_batch: int = 10 - """批量获取源地址数量""" - redirected_source: bool = False """是否使用重定向源""" available_nodes: str = "[]" """可用节点ID列表(JSON数组)""" - select_node: bool = False - """是否允许选择节点""" - - advance_delete: bool = False - """是否允许高级删除""" - # 反向关系 group: "Group" = Relationship(back_populates="options") -class Group(TableBase, table=True): +class Group(GroupBase, TableBase, table=True): """用户组模型""" name: str = Field(max_length=255, unique=True) diff --git a/models/migration.py b/models/migration.py index e9eb8bf..e2d779e 100644 --- a/models/migration.py +++ b/models/migration.py @@ -1,7 +1,7 @@ from .setting import Setting, SettingsType +from .user import ThemeResponse from pkg.conf.appmeta import BackendVersion -from .response import ThemeModel from pkg.password.pwd import Password from loguru import logger as log @@ -74,7 +74,7 @@ Neue',Helvetica,Arial,sans-serif; box-sizing: border-box; font-size: 14px; verti Setting(name="hot_share_num", value="10", type=SettingsType.SHARE), Setting(name="gravatar_server", value="https://www.gravatar.com/", type=SettingsType.AVATAR), Setting(name="defaultTheme", value="#3f51b5", type=SettingsType.BASIC), - Setting(name="themes", value=ThemeModel().model_dump_json(), type=SettingsType.BASIC), + Setting(name="themes", value=ThemeResponse().model_dump_json(), type=SettingsType.BASIC), Setting(name="aria2_token", value="", type=SettingsType.ARIA2), Setting(name="aria2_rpcurl", value="", type=SettingsType.ARIA2), Setting(name="aria2_temp_path", value="", type=SettingsType.ARIA2), diff --git a/models/object.py b/models/object.py index e35d2e6..754dfd0 100644 --- a/models/object.py +++ b/models/object.py @@ -1,8 +1,11 @@ -from typing import TYPE_CHECKING, Optional +from datetime import datetime +from typing import TYPE_CHECKING, Literal, Optional + from enum import StrEnum from sqlmodel import Field, Relationship, UniqueConstraint, CheckConstraint, Index -from .base import TableBase + +from .base import TableBase, SQLModelBase if TYPE_CHECKING: from .user import User @@ -17,7 +20,90 @@ class ObjectType(StrEnum): FOLDER = "folder" -class Object(TableBase, table=True): +# ==================== Base 模型 ==================== + +class ObjectBase(SQLModelBase): + """对象基础字段,供数据库模型和 DTO 共享""" + + name: str + """对象名称(文件名或目录名)""" + + type: ObjectType + """对象类型:file 或 folder""" + + size: int = 0 + """文件大小(字节),目录为 0""" + + +# ==================== DTO 模型 ==================== + +class DirectoryCreateRequest(SQLModelBase): + """创建目录请求 DTO""" + + path: str + """目录路径,如 /docs/images""" + + policy_id: int | None = None + """存储策略ID,不指定则继承父目录""" + + +class ObjectResponse(ObjectBase): + """对象响应 DTO""" + + id: str + """对象ID""" + + path: str + """对象路径""" + + thumb: bool = False + """是否有缩略图""" + + date: datetime + """对象修改时间""" + + create_date: datetime + """对象创建时间""" + + source_enabled: bool = False + """是否启用离线下载源""" + + +class PolicyResponse(SQLModelBase): + """存储策略响应 DTO""" + + id: str + """策略ID""" + + name: str + """策略名称""" + + type: Literal["local", "qiniu", "tencent", "aliyun", "onedrive", "google_drive", "dropbox", "webdav", "remote"] + """存储类型""" + + max_size: int = 0 + """单文件最大限制,单位字节,0表示不限制""" + + file_type: list[str] = [] + """允许的文件类型列表,空列表表示不限制""" + + +class DirectoryResponse(SQLModelBase): + """目录响应 DTO""" + + parent: str | None = None + """父目录ID,根目录为None""" + + objects: list[ObjectResponse] = [] + """目录下的对象列表""" + + policy: PolicyResponse + """存储策略""" + + +# ==================== 数据库模型 ==================== + +class Object(ObjectBase, TableBase, table=True): """ 统一对象模型 diff --git a/models/response.py b/models/response.py index ca575a7..8a89106 100644 --- a/models/response.py +++ b/models/response.py @@ -1,140 +1,26 @@ """ -响应模型定义 +通用响应模型定义 """ -from pydantic import BaseModel, Field -from typing import Literal, Union, Optional -from datetime import datetime, timezone +from typing import Any from uuid import uuid4 -class ResponseModel(BaseModel): - """ - 默认响应模型 - """ - code: int = Field(default=0, description="系统内部状态码, 0表示成功,其他表示失败", lt=60000, gt=0) - data: Union[dict, list, str, int, float, None] = Field(None, description="响应数据") - msg: str | None = Field(default=None, description="响应消息,可以是错误消息或信息提示") - instance_id: str = Field(default_factory=lambda: str(uuid4()), description="实例ID,用于标识请求的唯一性") - -class ThemeModel(BaseModel): - """ - 主题模型 - """ - primary: str = Field(default="#3f51b5", description="Primary color") - secondary: str = Field(default="#f50057", description="Secondary color") - accent: str = Field(default="#9c27b0", description="Accent color") - dark: str = Field(default="#1d1d1d", description="Dark color") - dark_page: str = Field(default="#121212", description="Dark page color") - positive: str = Field(default="#21ba45", description="Positive color") - negative: str = Field(default="#c10015", description="Negative color") - info: str = Field(default="#31ccec", description="Info color") - warning: str = Field(default="#f2c037", description="Warning color") +from sqlmodel import Field -class TokenModel(BaseModel): - """ - 访问令牌模型 - """ - access_expires: datetime = Field(default=None, description="访问令牌的过期时间") - access_token: str = Field(default=None, description="访问令牌") - refresh_expires: datetime = Field(default=None, description="刷新令牌的过期时间") - refresh_token: str = Field(default=None, description="刷新令牌") +from .base import SQLModelBase -class GroupModel(BaseModel): - """ - 用户组模型 - """ - id: int = Field(default=None, description="用户组ID") - name: str = Field(default=None, description="用户组名称") - allowShare: bool = Field(default=False, description="是否允许分享") - allowRomoteDownload: bool = Field(default=False, description="是否允许离线下载") - allowArchiveDownload: bool = Field(default=False, description="是否允许打包下载") - shareFree: bool = Field(default=False, description="是否允许免积分下载分享") - shareDownload: bool = Field(default=False, description="是否允许下载分享") - compress: bool = Field(default=False) - webdav: bool = Field(default=False, description="是否允许WebDAV") - allowWebDAVProxy: bool = Field(default=False, description="是否允许WebDAV代理") - relocate: bool = Field(default=False, description="是否使用重定向的下载链接") - sourceBatch: int = Field(default=0) - selectNode: bool = Field(default=False, description="是否允许选择离线下载节点") - advanceDelete: bool = Field(default=False, description="是否允许高级删除") -class UserModel(BaseModel): - """ - 用户模型 - """ - id: int = Field(default=None, description="用户ID") - username: str = Field(default=None, description="用户名") - nickname: str = Field(default=None, description="用户昵称") - status: bool = Field(default=0, description="用户状态") - avatar: Literal['default', 'gravatar', 'file'] = Field(default='default', description="头像类型") - created_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="用户创建时间") - preferred_theme: ThemeModel = Field(default_factory=ThemeModel, description="用户首选主题") - score: int = Field(default=0, description="用户积分") - anonymous: bool = Field(default=False, description="是否为匿名用户") - group: GroupModel = Field(default_factory=None, description="用户所属用户组") - tags: list = Field(default_factory=list, description="用户标签列表") - -class SiteConfigModel(ResponseModel): - """ - 站点配置模型 - """ - title: str = Field(default="DiskNext", description="网站标题") - themes: dict = Field(default_factory=dict, description="网站主题配置") - default_theme: dict = Field(description="默认主题RGB色号") - site_notice: str | None = Field(default=None, description="网站公告") - user: dict = Field(default_factory=dict, description="用户信息") - logo_light: str | None = Field(default=None, description="网站Logo URL") - logo_dark: str | None = Field(default=None, description="网站Logo URL(深色模式)") - captcha_type: Literal['none', 'default', 'gcaptcha', 'cloudflare turnstile'] = Field(default='none', description="验证码类型") - captcha_key: str | None = Field(default=None, description="验证码密钥") +class ResponseModel(SQLModelBase): + """通用响应模型""" -class AuthnModel(BaseModel): - """ - WebAuthn模型 - """ - id: str = Field(default=None, description="ID") - fingerprint: str = Field(default=None, description="指纹") + code: int = Field(default=0, ge=0, lt=60000) + """系统内部状态码,0表示成功,其他表示失败""" -class UserSettingModel(BaseModel): - """ - 用户设置模型 - """ - authn: Optional[AuthnModel] = Field(default=None, description="认证信息") - group_expires: datetime | None = Field(default=None, description="用户组过期时间") - prefer_theme: str = Field(default="#5898d4", description="用户首选主题") - qq: str | bool = Field(default=False, description="QQ号") - themes: dict = Field(default_factory=dict, description="用户主题配置") - two_factor: bool = Field(default=False, description="是否启用两步验证") - uid: int = Field(default=0, description="用户UID") + data: dict[str, Any] | list[Any] | str | int | float | None = None + """响应数据""" -class ObjectModel(BaseModel): - id: str = Field(default=..., description="对象ID") - name: str = Field(default=..., description="对象名称") - path: str = Field(default=..., description="对象路径") - thumb: bool = Field(default=False, description="是否有缩略图") - size: int = Field(default=None, description="对象大小,单位字节") - type: Literal['file', 'folder'] = Field(default=..., description="对象类型,file表示文件,folder表示文件夹") - date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="对象创建或修改时间") - create_date: datetime = Field(default_factory=lambda: datetime.now(timezone.utc), description="对象创建时间") - source_enabled: bool = Field(default=False, description="是否启用离线下载源") + msg: str | None = None + """响应消息,可以是错误消息或信息提示""" -class PolicyModel(BaseModel): - ''' - 存储策略模型 - ''' - id: str = Field(default=..., description="策略ID") - name: str = Field(default=..., description="策略名称") - type: Literal['local', 'qiniu', 'tencent', 'aliyun', 'onedrive', 'google_drive', 'dropbox', 'webdav', 'remote'] = Field(default=..., description="存储类型") - max_size: int = Field(default=0, description="单文件最大限制,单位字节,0表示不限制") - file_type: list = Field(default_factory=list, description="允许的文件类型列表,空列表表示不限制") - -class DirectoryModel(BaseModel): - ''' - 目录模型 - ''' - - parent: str | None - """父目录ID,根目录为None""" - - objects: list[ObjectModel] = Field(default_factory=list, description="目录下的对象列表") - policy: PolicyModel = Field(default_factory=PolicyModel, description="存储策略") \ No newline at end of file + instance_id: str = Field(default_factory=lambda: str(uuid4())) + """实例ID,用于标识请求的唯一性""" diff --git a/models/setting.py b/models/setting.py index 4373f23..531bf33 100644 --- a/models/setting.py +++ b/models/setting.py @@ -1,7 +1,46 @@ +from typing import Literal + from sqlmodel import Field, UniqueConstraint -from .base import TableBase + +from .base import TableBase, SQLModelBase from enum import StrEnum + +# ==================== DTO 模型 ==================== + +class SiteConfigResponse(SQLModelBase): + """站点配置响应 DTO""" + + title: str = "DiskNext" + """网站标题""" + + themes: dict[str, str] = {} + """网站主题配置""" + + default_theme: dict[str, str] = {} + """默认主题RGB色号""" + + site_notice: str | None = None + """网站公告""" + + user: dict[str, str | int | bool] = {} + """用户信息""" + + logo_light: str | None = None + """网站Logo URL""" + + logo_dark: str | None = None + """网站Logo URL(深色模式)""" + + captcha_type: Literal["none", "default", "gcaptcha", "cloudflare turnstile"] = "none" + """验证码类型""" + + captcha_key: str | None = None + """验证码密钥""" + + +# ==================== 数据库模型 ==================== + class SettingsType(StrEnum): """设置类型枚举""" diff --git a/models/user.py b/models/user.py index ec02e58..04e3e9e 100644 --- a/models/user.py +++ b/models/user.py @@ -1,8 +1,7 @@ from datetime import datetime -from typing import Optional, TYPE_CHECKING +from typing import Literal, Optional, TYPE_CHECKING from sqlmodel import Field, Relationship -from pydantic import BaseModel from .base import TableBase, SQLModelBase @@ -21,23 +20,48 @@ if TYPE_CHECKING: """ Option 需求 - 主题 跟随系统/浅色/深色 -- 颜色方案 参考.response.ThemeModel +- 颜色方案 参考 ThemeResponse - 语言 - 时区 - 切换到不同存储策略是否提醒 """ -class LoginRequest(BaseModel): - """ - 登录请求模型 - """ - username: str = Field(..., description="用户名或邮箱") - password: str = Field(..., description="用户密码") - captcha: str | None = Field(None, description="验证码") - twoFaCode: str | None = Field(None, description="两步验证代码") -class WebAuthnInfo(BaseModel): - """WebAuthn 信息模型""" +# ==================== Base 模型 ==================== + +class UserBase(SQLModelBase): + """用户基础字段,供数据库模型和 DTO 共享""" + + username: str + """用户名""" + + status: bool = True + """用户状态: True=正常, False=封禁""" + + score: int = 0 + """用户积分""" + + +# ==================== DTO 模型 ==================== + +class LoginRequest(SQLModelBase): + """登录请求 DTO""" + + username: str + """用户名或邮箱""" + + password: str + """用户密码""" + + captcha: str | None = None + """验证码""" + + two_fa_code: str | None = None + """两步验证代码""" + + +class WebAuthnInfo(SQLModelBase): + """WebAuthn 信息 DTO""" credential_id: str """凭证 ID""" @@ -57,12 +81,152 @@ class WebAuthnInfo(BaseModel): transports: list[str] """支持的传输方式""" -class User(TableBase, table=True): + +class ThemeResponse(SQLModelBase): + """主题响应 DTO""" + + primary: str = "#3f51b5" + """主色调""" + + secondary: str = "#f50057" + """次要色""" + + accent: str = "#9c27b0" + """强调色""" + + dark: str = "#1d1d1d" + """深色""" + + dark_page: str = "#121212" + """深色页面背景""" + + positive: str = "#21ba45" + """正面/成功色""" + + negative: str = "#c10015" + """负面/错误色""" + + info: str = "#31ccec" + """信息色""" + + warning: str = "#f2c037" + """警告色""" + + +class TokenResponse(SQLModelBase): + """访问令牌响应 DTO""" + + access_expires: datetime + """访问令牌过期时间""" + + access_token: str + """访问令牌""" + + refresh_expires: datetime + """刷新令牌过期时间""" + + refresh_token: str + """刷新令牌""" + + +class UserResponse(UserBase): + """用户响应 DTO""" + + id: int + """用户ID""" + + nickname: str | None = None + """用户昵称""" + + avatar: Literal["default", "gravatar", "file"] = "default" + """头像类型""" + + created_at: datetime + """用户创建时间""" + + preferred_theme: ThemeResponse | None = None + """用户首选主题""" + + anonymous: bool = False + """是否为匿名用户""" + + group: "GroupResponse | None" = None + """用户所属用户组""" + + tags: list[str] = [] + """用户标签列表""" + + +class UserPublic(UserBase): + """用户公开信息 DTO,用于 API 响应""" + + id: int | None = None + """用户ID""" + + nick: str | None = None + """昵称""" + + storage: int = 0 + """已用存储空间(字节)""" + + avatar: str | None = None + """头像地址""" + + group_expires: datetime | None = None + """用户组过期时间""" + + group_id: int | None = None + """所属用户组ID""" + + created_at: datetime | None = None + """创建时间""" + + updated_at: datetime | None = None + """更新时间""" + + +class UserSettingResponse(SQLModelBase): + """用户设置响应 DTO""" + + authn: "AuthnResponse | None" = None + """认证信息""" + + group_expires: datetime | None = None + """用户组过期时间""" + + prefer_theme: str = "#5898d4" + """用户首选主题""" + + qq: str | None = None + """QQ号""" + + themes: dict[str, str] = {} + """用户主题配置""" + + two_factor: bool = False + """是否启用两步验证""" + + uid: int = 0 + """用户UID""" + + +# 前向引用导入 +from .group import GroupResponse # noqa: E402 +from .user_authn import AuthnResponse # noqa: E402 + +# 更新前向引用 +UserResponse.model_rebuild() +UserSettingResponse.model_rebuild() + + +# ==================== 数据库模型 ==================== + +class User(UserBase, TableBase, table=True): """用户模型""" username: str = Field(max_length=50, unique=True, index=True) """用户名,唯一,一经注册不可更改""" - + nick: str | None = Field(default=None, max_length=50) """用于公开展示的名字,可使用真实姓名或昵称""" @@ -81,10 +245,9 @@ class User(TableBase, table=True): avatar: str | None = Field(default=None, max_length=255) """头像地址""" - options: str | None = Field(default=None) + options: str | None = None """[TODO] 用户个人设置 需要更改,参考上方的需求""" - github_open_id: str | None = Field(default=None, unique=True, index=True) """Github OpenID""" @@ -94,7 +257,7 @@ class User(TableBase, table=True): score: int = Field(default=0, sa_column_kwargs={"server_default": "0"}, ge=0) """用户积分""" - group_expires: datetime | None = Field(default=None) + group_expires: datetime | None = None """当前用户组过期时间""" phone: str | None = Field(default=None, max_length=32, unique=True, index=True) @@ -108,7 +271,7 @@ class User(TableBase, table=True): """之前的用户组ID(用于过期后恢复)""" # [TODO] 待考虑:根目录 Object ID - + # 关系 group: "Group" = Relationship( back_populates="user", @@ -122,7 +285,7 @@ class User(TableBase, table=True): "foreign_keys": "User.previous_group_id" } ) - + downloads: list["Download"] = Relationship(back_populates="user") objects: list["Object"] = Relationship(back_populates="owner") """用户的所有对象(文件和目录)""" @@ -137,41 +300,4 @@ class User(TableBase, table=True): def to_public(self) -> "UserPublic": """转换为公开 DTO,排除敏感字段""" return UserPublic.model_validate(self) - - -class UserPublic(SQLModelBase): - """用户公开信息 DTO,用于 API 响应""" - - id: int | None = None - """用户ID""" - - username: str - """用户名""" - - nick: str | None = None - """昵称""" - - status: bool = True - """用户状态""" - - storage: int = 0 - """已用存储空间(字节)""" - - avatar: str | None = None - """头像地址""" - - score: int = 0 - """用户积分""" - - group_expires: datetime | None = None - """用户组过期时间""" - - group_id: int - """所属用户组ID""" - - created_at: datetime | None = None - """创建时间""" - - updated_at: datetime | None = None - """更新时间""" \ No newline at end of file diff --git a/models/user_authn.py b/models/user_authn.py index c0d782a..12c8ca2 100644 --- a/models/user_authn.py +++ b/models/user_authn.py @@ -3,12 +3,26 @@ from typing import TYPE_CHECKING from sqlalchemy import Column, Text from sqlmodel import Field, Relationship -from .base import TableBase +from .base import TableBase, SQLModelBase if TYPE_CHECKING: from .user import User +# ==================== DTO 模型 ==================== + +class AuthnResponse(SQLModelBase): + """WebAuthn 响应 DTO""" + + id: str + """凭证ID""" + + fingerprint: str + """凭证指纹""" + + +# ==================== 数据库模型 ==================== + class UserAuthn(TableBase, table=True): """用户 WebAuthn 凭证模型,与 User 为多对一关系""" diff --git a/pkg/JWT/jwt.py b/pkg/JWT/jwt.py index 622f1dc..97ec875 100644 --- a/pkg/JWT/jwt.py +++ b/pkg/JWT/jwt.py @@ -1,31 +1,33 @@ -from fastapi.security import OAuth2PasswordBearer -from models.setting import Setting -from models.database import get_session from datetime import datetime, timedelta, timezone -from sqlalchemy import and_ + import jwt +from fastapi.security import OAuth2PasswordBearer oauth2_scheme = OAuth2PasswordBearer( scheme_name='获取 JWT Bearer 令牌', description='用于获取 JWT Bearer 令牌,需要以表单的形式提交', tokenUrl="/api/user/session", - ) +) SECRET_KEY = '' + async def load_secret_key() -> None: """ 从数据库读取 JWT 的密钥。 """ + # 延迟导入以避免循环依赖 + from models.database import get_session + from models.setting import Setting + global SECRET_KEY async for session in get_session(): setting = await Setting.get( session, - and_(Setting.type == "auth", Setting.name == "secret_key") + (Setting.type == "auth") & (Setting.name == "secret_key") ) if setting: SECRET_KEY = setting.value - break # 访问令牌 def create_access_token(data: dict, expires_delta: timedelta | None = None) -> tuple[str, datetime]: diff --git a/pkg/__init__.py b/pkg/__init__.py new file mode 100644 index 0000000..d8d4fd5 --- /dev/null +++ b/pkg/__init__.py @@ -0,0 +1,4 @@ +# 延迟导入以避免循环依赖 +# JWT 和 lifespan 应在需要时直接从子模块导入 +# from .JWT import JWT +# from .lifespan import lifespan \ No newline at end of file diff --git a/pkg/conf/appmeta.py b/pkg/conf/appmeta.py index c26e1b7..66920cc 100644 --- a/pkg/conf/appmeta.py +++ b/pkg/conf/appmeta.py @@ -18,11 +18,6 @@ debug: bool = os.getenv("DEBUG", "false").lower() in ("true", "1", "yes") or Fal if debug: log.info("Debug mode is enabled. This is not recommended for production use.") -host: str = os.getenv("HOST", "0.0.0.0") -port: int = int(os.getenv("PORT", 5213)) - -log.info(f"Starting DiskNext Server {BackendVersion} on {host}:{port}") - database_url: str = os.getenv("DATABASE_URL", "sqlite+aiosqlite:///disknext.db") tags_meta = [ diff --git a/routers/controllers/directory.py b/routers/controllers/directory.py index 4caed14..3d468cd 100644 --- a/routers/controllers/directory.py +++ b/routers/controllers/directory.py @@ -1,27 +1,25 @@ from typing import Annotated from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel from middleware.auth import AuthRequired from middleware.dependencies import SessionDep -from models import Object, ObjectType, User, response +from models import ( + DirectoryCreateRequest, + DirectoryResponse, + Object, + ObjectResponse, + ObjectType, + PolicyResponse, + User, + response, +) directory_router = APIRouter( prefix="/directory", tags=["directory"] ) - -class DirectoryCreateRequest(BaseModel): - """创建目录请求""" - - path: str - """目录路径,如 /docs/images""" - - policy_id: int | None = None - """存储策略ID,不指定则继承父目录""" - @directory_router.get( path="/{path:path}", summary="获取目录内容", @@ -51,7 +49,7 @@ async def router_directory_get( policy = await folder.awaitable_attrs.policy objects = [ - response.ObjectModel( + ObjectResponse( id=str(child.id), name=child.name, path=f"/{child.name}", # TODO: 完整路径 @@ -66,16 +64,16 @@ async def router_directory_get( ] return response.ResponseModel( - data=response.DirectoryModel( + data=DirectoryResponse( parent=str(folder.parent_id) if folder.parent_id else None, objects=objects, - policy=response.PolicyModel( + policy=PolicyResponse( id=str(policy.id), name=policy.name, type=policy.type.value, max_size=policy.max_size, file_type=[], - ) + ), ) ) diff --git a/routers/controllers/user.py b/routers/controllers/user.py index 3ee93bf..93ad9e0 100644 --- a/routers/controllers/user.py +++ b/routers/controllers/user.py @@ -30,16 +30,16 @@ user_settings_router = APIRouter( async def router_user_session( session: SessionDep, form_data: Annotated[OAuth2PasswordRequestForm, Depends()], -) -> models.response.TokenModel: +) -> models.TokenResponse: username = form_data.username password = form_data.password result = await service.user.Login( session, - models.user.LoginRequest(username=username, password=password), + models.LoginRequest(username=username, password=password), ) - if isinstance(result, models.response.TokenModel): + if isinstance(result, models.TokenResponse): return result elif result is None: raise HTTPException(status_code=401, detail="Invalid username or password") @@ -203,13 +203,13 @@ async def router_user_me( """ group = await models.Group.get(session, models.Group.id == user.group_id) - user_group = models.response.GroupModel( + user_group = models.GroupResponse( id=group.id, name=group.name, - allowShare=group.share_enabled, + allow_share=group.share_enabled, ) - users = models.response.UserModel( + users = models.UserResponse( id=user.id, username=user.username, nickname=user.nick, @@ -369,7 +369,7 @@ def router_user_settings() -> models.response.ResponseModel: Returns: dict: A dictionary containing the current user settings. """ - return models.response.ResponseModel(data=models.response.UserSettingModel().model_dump()) + return models.response.ResponseModel(data=models.UserSettingResponse().model_dump()) @user_settings_router.post( path='/avatar', diff --git a/service/user/login.py b/service/user/login.py index 0899bc6..fff8fec 100644 --- a/service/user/login.py +++ b/service/user/login.py @@ -1,25 +1,22 @@ from loguru import logger as log -from sqlalchemy import and_ from sqlmodel.ext.asyncio.session import AsyncSession -from models.response import TokenModel -from models import user -from models.user import User -from pkg.JWT.jwt import create_access_token, create_refresh_token +from models import LoginRequest, TokenResponse, User +from pkg.JWT.JWT import create_access_token, create_refresh_token -async def Login(session: AsyncSession, login_request: user.LoginRequest) -> TokenModel | bool | None: +async def Login(session: AsyncSession, login_request: LoginRequest) -> TokenResponse | bool | None: """ 根据账号密码进行登录。 - 如果登录成功,返回一个 TokenModel 对象,包含访问令牌和刷新令牌以及它们的过期时间。 + 如果登录成功,返回一个 TokenResponse 对象,包含访问令牌和刷新令牌以及它们的过期时间。 如果登录异常,返回 `False`(未完成注册或账号被封禁)。 如果登录失败,返回 `None`。 :param session: 数据库会话 :param login_request: 登录请求 - :return: TokenModel 对象或状态码或 None + :return: TokenResponse 对象或状态码或 None """ from pkg.password.pwd import Password @@ -52,7 +49,7 @@ async def Login(session: AsyncSession, login_request: user.LoginRequest) -> Toke access_token, access_expire = create_access_token(data={'sub': current_user.username}) refresh_token, refresh_expire = create_refresh_token(data={'sub': current_user.username}) - return TokenModel( + return TokenResponse( access_token=access_token, access_expires=access_expire, refresh_token=refresh_token,