Initial commit: MyBlog full stack blog
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""MyBlog 后端包。"""
|
||||
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
用户认证模块。
|
||||
|
||||
职责:
|
||||
- bcrypt 密码哈希与校验(禁止明文保存密码)
|
||||
- JWT 令牌的生成与解析(载荷包含用户 id、角色、过期时间)
|
||||
- FastAPI 依赖 get_current_user:从请求头解析当前登录用户
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import bcrypt
|
||||
import jwt
|
||||
from dotenv import load_dotenv
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .database import PROJECT_ROOT, get_db
|
||||
from .models import User
|
||||
|
||||
# 加载项目根目录下的 .env,确保 JWT_SECRET 可用(重复调用无副作用)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
# JWT 签名密钥:必须在 .env 中配置。
|
||||
# 缺失时直接报错(fail-fast),禁止回退到硬编码默认密钥,避免生产环境使用弱密钥。
|
||||
JWT_SECRET = os.getenv("JWT_SECRET", "").strip()
|
||||
if not JWT_SECRET:
|
||||
raise RuntimeError(
|
||||
"缺少 JWT_SECRET 环境变量:请在项目根目录 .env 中配置强随机密钥后重试"
|
||||
)
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
|
||||
# 令牌默认有效期:7 天,可通过 .env 的 JWT_EXPIRE_MINUTES 覆盖
|
||||
JWT_EXPIRE_MINUTES = int(os.getenv("JWT_EXPIRE_MINUTES") or str(7 * 24 * 60))
|
||||
|
||||
# HTTP Bearer 令牌认证方案(auto_error=False:缺失令牌时不自动报错,便于统一错误返回格式)
|
||||
bearer_scheme = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
"""使用 bcrypt 生成密码哈希,返回可直接存入数据库的字符串。"""
|
||||
salt = bcrypt.gensalt()
|
||||
return bcrypt.hashpw(password.encode("utf-8"), salt).decode("utf-8")
|
||||
|
||||
|
||||
def verify_password(password: str, password_hash: str) -> bool:
|
||||
"""校验明文密码与 bcrypt 哈希是否匹配。"""
|
||||
return bcrypt.checkpw(password.encode("utf-8"), password_hash.encode("utf-8"))
|
||||
|
||||
|
||||
def create_access_token(user: User) -> str:
|
||||
"""生成 JWT 令牌:载荷包含用户 id、角色与过期时间。"""
|
||||
now = datetime.now(timezone.utc)
|
||||
payload = {
|
||||
"sub": str(user.id), # 用户 id(subject)
|
||||
"role": user.role,
|
||||
"ver": user.token_version, # 令牌版本(重置密码后旧令牌失效)
|
||||
"iat": now, # 签发时间
|
||||
"exp": now + timedelta(minutes=JWT_EXPIRE_MINUTES), # 过期时间
|
||||
}
|
||||
return jwt.encode(payload, JWT_SECRET, algorithm=JWT_ALGORITHM)
|
||||
|
||||
|
||||
def decode_token(token: str) -> dict:
|
||||
"""解析并校验 JWT 令牌,返回载荷;令牌无效或已过期时抛出 401。"""
|
||||
try:
|
||||
return jwt.decode(token, JWT_SECRET, algorithms=[JWT_ALGORITHM])
|
||||
except jwt.PyJWTError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="登录状态无效或已过期,请重新登录",
|
||||
)
|
||||
|
||||
|
||||
def get_current_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> User:
|
||||
"""FastAPI 依赖:从 Authorization: Bearer <token> 解析并返回当前用户。"""
|
||||
if credentials is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未登录,请先登录")
|
||||
payload = decode_token(credentials.credentials)
|
||||
user_id = payload.get("sub")
|
||||
try:
|
||||
user_id = int(user_id)
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌无效")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
|
||||
# 令牌版本校验:重置密码后 token_version 自增,旧令牌立即失效(吊销机制)
|
||||
if (payload.get("ver") or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
|
||||
return user
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
数据库连接与初始化模块。
|
||||
|
||||
职责:
|
||||
- 建立 SQLite + SQLAlchemy 连接(引擎)
|
||||
- 提供会话工厂 SessionLocal 与 FastAPI 依赖 get_db
|
||||
- 提供数据库初始化函数 init_db()
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
from dotenv import load_dotenv
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import declarative_base, sessionmaker
|
||||
|
||||
# 定位 backend 目录与项目根目录(与 backend 同级)
|
||||
BACKEND_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = BACKEND_DIR.parent
|
||||
|
||||
# 读取项目根目录下的 .env 配置文件(若存在)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
# 数据库地址:默认使用 backend 目录下的 SQLite 文件,可通过 .env 的 DATABASE_URL 覆盖(留空视为使用默认值)
|
||||
DATABASE_URL = os.getenv("DATABASE_URL") or f"sqlite:///{(BACKEND_DIR / 'blog.db').as_posix()}"
|
||||
|
||||
# 创建 SQLAlchemy 引擎
|
||||
# SQLite 需关闭 check_same_thread:FastAPI 多线程处理请求时会跨线程复用会话
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} if DATABASE_URL.startswith("sqlite") else {},
|
||||
)
|
||||
|
||||
# 会话工厂:每个请求创建独立会话,避免线程安全问题
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
# 所有 ORM 模型的公共声明基类
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
def get_db():
|
||||
"""FastAPI 依赖注入:提供数据库会话,请求结束后自动关闭。"""
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
"""初始化数据库:创建所有已注册 ORM 模型对应的数据表。"""
|
||||
# 延迟导入模型,确保全部模型注册到 Base.metadata 后再建表
|
||||
from . import models # noqa: F401
|
||||
|
||||
Base.metadata.create_all(bind=engine)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 支持直接运行:python -m backend.database
|
||||
init_db()
|
||||
print(f"数据库初始化完成:{DATABASE_URL}")
|
||||
@@ -0,0 +1,55 @@
|
||||
"""
|
||||
邮件发送模块。
|
||||
|
||||
通过 SMTP 发送验证码邮件,配置项从 .env 读取:
|
||||
- SMTP_HOST / SMTP_PORT / SMTP_USER / SMTP_AUTH_CODE / EMAIL_FROM
|
||||
163 邮箱:smtp.163.com,端口 465(SSL),密码处填写授权码。
|
||||
"""
|
||||
|
||||
import os
|
||||
import smtplib
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
from email.utils import formataddr
|
||||
|
||||
from dotenv import load_dotenv
|
||||
|
||||
from .database import PROJECT_ROOT
|
||||
|
||||
# 加载项目根目录下的 .env(重复调用无副作用)
|
||||
load_dotenv(PROJECT_ROOT / ".env")
|
||||
|
||||
SMTP_HOST = os.getenv("SMTP_HOST", "smtp.163.com")
|
||||
SMTP_PORT = int(os.getenv("SMTP_PORT") or "465")
|
||||
SMTP_USER = os.getenv("SMTP_USER", "")
|
||||
SMTP_AUTH_CODE = os.getenv("SMTP_AUTH_CODE", "")
|
||||
EMAIL_FROM = os.getenv("EMAIL_FROM", SMTP_USER)
|
||||
|
||||
|
||||
def send_verification_code(to_email: str, code: str) -> None:
|
||||
"""向指定邮箱发送验证码邮件;发送失败时抛出异常由调用方处理。"""
|
||||
if not SMTP_USER or not SMTP_AUTH_CODE:
|
||||
raise RuntimeError("SMTP 未配置:请在 .env 中设置 SMTP_USER / SMTP_AUTH_CODE")
|
||||
|
||||
subject = "博客 - 邮箱验证码"
|
||||
body = (
|
||||
f"您好,\n\n"
|
||||
f"您的验证码是:{code}\n"
|
||||
f"验证码 10 分钟内有效,请勿泄露给他人。\n\n"
|
||||
f"如非本人操作,请忽略本邮件。"
|
||||
)
|
||||
message = MIMEText(body, "plain", "utf-8")
|
||||
message["Subject"] = Header(subject, "utf-8")
|
||||
message["From"] = formataddr(("MyBlog", EMAIL_FROM))
|
||||
message["To"] = to_email
|
||||
|
||||
if SMTP_PORT == 465:
|
||||
server = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT, timeout=15)
|
||||
else:
|
||||
server = smtplib.SMTP(SMTP_HOST, SMTP_PORT, timeout=15)
|
||||
server.starttls()
|
||||
try:
|
||||
server.login(SMTP_USER, SMTP_AUTH_CODE)
|
||||
server.sendmail(EMAIL_FROM, [to_email], message.as_string())
|
||||
finally:
|
||||
server.quit()
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
FastAPI 应用入口。
|
||||
|
||||
严格遵守全局架构,本文件只负责:
|
||||
1. 创建 FastAPI 实例
|
||||
2. 注册路由(router)
|
||||
3. 统一全局异常返回格式({success, data, message})
|
||||
"""
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
|
||||
from .routers import api_router
|
||||
from .schemas import UnifiedResponse
|
||||
|
||||
# 创建 FastAPI 实例
|
||||
app = FastAPI(
|
||||
title="MyBlog API",
|
||||
description="个人博客系统后端 API(FastAPI + SQLite + JWT)",
|
||||
version="0.1.0",
|
||||
)
|
||||
|
||||
|
||||
def _unified_error(status_code: int, message: str) -> JSONResponse:
|
||||
"""构造符合全局统一格式的错误响应。"""
|
||||
return JSONResponse(
|
||||
status_code=status_code,
|
||||
content=UnifiedResponse(success=False, data=None, message=message).model_dump(),
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StarletteHTTPException)
|
||||
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
|
||||
"""统一 HTTP 异常(业务错误)的返回格式。"""
|
||||
return _unified_error(exc.status_code, str(exc.detail))
|
||||
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
||||
"""统一请求参数校验错误的返回格式。"""
|
||||
return _unified_error(422, "请求参数校验失败")
|
||||
|
||||
|
||||
# 注册路由:后续任务在 routers 包中实现各业务路由后统一挂载
|
||||
app.include_router(api_router)
|
||||
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
数据库 ORM 模型模块。
|
||||
|
||||
定义全局架构中的五个核心模型,以及邮箱验证码模型:
|
||||
- User:用户(visitor / friend / blogger)
|
||||
- Article:文章(public / friend 两种可见性)
|
||||
- Comment:评论(支持回复,parent_id 指向父评论)
|
||||
- Like:点赞
|
||||
- Friend:好友申请
|
||||
- EmailCode:邮箱验证码(一次性、带过期时间)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from .database import Base
|
||||
|
||||
# ---------- 常量定义 ----------
|
||||
|
||||
# 用户角色(全局架构:visitor / friend / blogger)
|
||||
ROLE_VISITOR = "visitor"
|
||||
ROLE_FRIEND = "friend"
|
||||
ROLE_BLOGGER = "blogger"
|
||||
|
||||
# 文章可见性(全局架构:public / friend)
|
||||
VISIBILITY_PUBLIC = "public"
|
||||
VISIBILITY_FRIEND = "friend"
|
||||
|
||||
# 文章分区(life 生活 / study 学习)
|
||||
ARTICLE_CATEGORY_LIFE = "life"
|
||||
ARTICLE_CATEGORY_STUDY = "study"
|
||||
|
||||
# 项目类型(L0 静态托管 / GitHub 外链)
|
||||
PROJECT_TYPE_STATIC = "static"
|
||||
PROJECT_TYPE_LINK = "link"
|
||||
|
||||
# 好友申请状态
|
||||
FRIEND_STATUS_PENDING = "pending"
|
||||
FRIEND_STATUS_ACCEPTED = "accepted"
|
||||
FRIEND_STATUS_REJECTED = "rejected"
|
||||
|
||||
# 邮箱验证码用途
|
||||
PURPOSE_REGISTER = "register"
|
||||
PURPOSE_RESET = "reset"
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
"""返回当前 UTC 时间(无时区),作为各模型时间字段的默认值。"""
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""用户模型:对应权限系统的 visitor / friend / blogger 三种角色。"""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="用户 ID")
|
||||
email = Column(String(255), unique=True, index=True, nullable=False, comment="邮箱,唯一")
|
||||
username = Column(String(50), unique=True, index=True, nullable=False, comment="用户名,唯一")
|
||||
password_hash = Column(String(255), nullable=False, comment="bcrypt 密码哈希,禁止保存明文")
|
||||
role = Column(String(20), nullable=False, default=ROLE_VISITOR, comment="角色")
|
||||
avatar = Column(String(500), nullable=True, comment="头像图片路径(uploads/avatar/)")
|
||||
avatar_updated_time = Column(DateTime, nullable=True, comment="头像最近一次更换时间(配合更换频率限制)")
|
||||
bio = Column(Text, nullable=True, comment="个人简介(我的简介页面展示)")
|
||||
token_version = Column(Integer, nullable=False, default=0, comment="令牌版本号(重置密码时自增,使旧 JWT 立即失效)")
|
||||
created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间")
|
||||
|
||||
# 关联关系:删除用户时级联删除其文章、评论、点赞与好友记录
|
||||
articles = relationship("Article", back_populates="author", cascade="all, delete-orphan")
|
||||
comments = relationship("Comment", back_populates="user", cascade="all, delete-orphan")
|
||||
likes = relationship("Like", back_populates="user", cascade="all, delete-orphan")
|
||||
friends = relationship("Friend", back_populates="user", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Article(Base):
|
||||
"""文章模型:Markdown 正文,支持 public / friend 两种可见性。"""
|
||||
|
||||
__tablename__ = "articles"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="文章 ID")
|
||||
title = Column(String(200), nullable=False, comment="文章标题")
|
||||
content = Column(Text, nullable=False, comment="Markdown 格式正文")
|
||||
cover = Column(String(500), nullable=True, comment="文章封面图片路径(uploads/article/)")
|
||||
visibility = Column(String(20), nullable=False, default=VISIBILITY_PUBLIC, comment="可见性")
|
||||
category = Column(String(30), nullable=False, default=ARTICLE_CATEGORY_LIFE, index=True, comment="分区:life 生活 / study 学习")
|
||||
author_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="作者 ID")
|
||||
created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间")
|
||||
|
||||
author = relationship("User", back_populates="articles")
|
||||
comments = relationship("Comment", back_populates="article", cascade="all, delete-orphan")
|
||||
likes = relationship("Like", back_populates="article", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class Project(Base):
|
||||
"""项目模型:L0 静态托管(static,自动在线演示)或 GitHub 外链(link)。"""
|
||||
|
||||
__tablename__ = "projects"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="项目 ID")
|
||||
name = Column(String(100), nullable=False, comment="项目名称")
|
||||
description = Column(Text, nullable=True, comment="项目简介")
|
||||
tech = Column(String(500), nullable=True, comment="技术标签(逗号分隔)")
|
||||
project_type = Column(String(20), nullable=False, default=PROJECT_TYPE_STATIC, comment="项目类型:static 静态托管 / link 外链")
|
||||
visibility = Column(String(20), nullable=False, default=VISIBILITY_PUBLIC, comment="可见性:public 公开 / friend 好友")
|
||||
demo_url = Column(String(500), nullable=True, comment="在线运行地址(static 为站内演示地址,link 为 GitHub 链接)")
|
||||
download_url = Column(String(500), nullable=True, comment="下载文件地址(uploads/project/)")
|
||||
github_url = Column(String(500), nullable=True, comment="GitHub 仓库链接(link 类型必填)")
|
||||
created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间")
|
||||
updated_time = Column(DateTime, nullable=False, default=utcnow, onupdate=utcnow, comment="更新时间")
|
||||
|
||||
|
||||
class Comment(Base):
|
||||
"""评论模型:好友及博主可对文章发表评论,支持回复(parent_id)。"""
|
||||
|
||||
__tablename__ = "comments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="评论 ID")
|
||||
article_id = Column(Integer, ForeignKey("articles.id"), nullable=False, index=True, comment="文章 ID")
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="评论者 ID")
|
||||
parent_id = Column(Integer, ForeignKey("comments.id"), nullable=True, index=True, comment="父评论 ID(回复时填写,顶层评论为空)")
|
||||
content = Column(Text, nullable=False, comment="评论内容")
|
||||
created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间")
|
||||
|
||||
article = relationship("Article", back_populates="comments")
|
||||
user = relationship("User", back_populates="comments")
|
||||
parent = relationship("Comment", remote_side=[id], back_populates="replies")
|
||||
replies = relationship("Comment", back_populates="parent")
|
||||
|
||||
|
||||
class Like(Base):
|
||||
"""点赞模型:同一用户对同一文章只能点赞一次。"""
|
||||
|
||||
__tablename__ = "likes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="点赞记录 ID")
|
||||
article_id = Column(Integer, ForeignKey("articles.id"), nullable=False, index=True, comment="文章 ID")
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="点赞者 ID")
|
||||
|
||||
# 唯一约束:防止同一用户重复点赞同一文章
|
||||
__table_args__ = (
|
||||
UniqueConstraint("article_id", "user_id", name="uq_like_article_user"),
|
||||
)
|
||||
|
||||
article = relationship("Article", back_populates="likes")
|
||||
user = relationship("User", back_populates="likes")
|
||||
|
||||
|
||||
class Friend(Base):
|
||||
"""好友申请模型:按已确认架构字段(id / user_id / status)实现。"""
|
||||
|
||||
__tablename__ = "friends"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="好友记录 ID")
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True, comment="申请人 ID")
|
||||
status = Column(String(20), nullable=False, default=FRIEND_STATUS_PENDING, comment="申请状态")
|
||||
created_time = Column(DateTime, nullable=False, default=utcnow, comment="申请创建时间")
|
||||
|
||||
user = relationship("User", back_populates="friends")
|
||||
|
||||
|
||||
class EmailCode(Base):
|
||||
"""邮箱验证码模型:一次性验证码,带过期时间。"""
|
||||
|
||||
__tablename__ = "email_codes"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="验证码记录 ID")
|
||||
email = Column(String(255), index=True, nullable=False, comment="目标邮箱")
|
||||
code = Column(String(10), nullable=False, comment="验证码")
|
||||
purpose = Column(String(30), nullable=False, comment="用途:register / reset")
|
||||
expires_at = Column(DateTime, nullable=False, comment="过期时间")
|
||||
used = Column(Boolean, nullable=False, default=False, comment="是否已使用")
|
||||
created_time = Column(DateTime, nullable=False, default=utcnow, comment="创建时间")
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
API 路由包。
|
||||
|
||||
各业务路由(认证、文章、评论、点赞、好友、邮箱验证码、密码重置、上传、
|
||||
项目、WASM 预编译)在各自模块中实现,并在此统一汇总挂载。
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .article import router as article_router
|
||||
from .comment import router as comment_router
|
||||
from .email import router as email_router
|
||||
from .friend import router as friend_router
|
||||
from .ipwatch import router as ipwatch_router
|
||||
from .like import router as like_router
|
||||
from .password import router as password_router
|
||||
from .project import router as project_router
|
||||
from .upload import router as upload_router
|
||||
from .user import router as user_router
|
||||
from .wasm import router as wasm_router
|
||||
|
||||
# 统一业务路由
|
||||
api_router = APIRouter()
|
||||
api_router.include_router(user_router)
|
||||
api_router.include_router(article_router)
|
||||
api_router.include_router(comment_router)
|
||||
api_router.include_router(friend_router)
|
||||
api_router.include_router(like_router)
|
||||
api_router.include_router(email_router)
|
||||
api_router.include_router(password_router)
|
||||
api_router.include_router(project_router)
|
||||
api_router.include_router(upload_router)
|
||||
api_router.include_router(ipwatch_router)
|
||||
api_router.include_router(wasm_router)
|
||||
@@ -0,0 +1,307 @@
|
||||
"""
|
||||
文章路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/article/add 发布文章(仅 blogger),支持封面与 public / friend 可见性
|
||||
- GET /api/article/list 文章列表(分页):所有用户可见;好友文章对游客仅展示标题与封面
|
||||
- GET /api/article/{id} 文章详情:好友文章对游客仅返回标题与封面,不返回正文
|
||||
- PUT /api/article/{id} 更新文章(仅 blogger,字段可选)
|
||||
- DELETE /api/article/{id} 删除文章(仅 blogger,级联删除评论与点赞)
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import PROJECT_ROOT, get_db
|
||||
from ..models import (
|
||||
ARTICLE_CATEGORY_LIFE,
|
||||
ARTICLE_CATEGORY_STUDY,
|
||||
ROLE_BLOGGER,
|
||||
VISIBILITY_FRIEND,
|
||||
VISIBILITY_PUBLIC,
|
||||
Article,
|
||||
User,
|
||||
)
|
||||
from ..schemas import ArticleCreate, ArticleUpdate, UnifiedResponse
|
||||
from .deps import can_read_friend_article, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/article", tags=["article"])
|
||||
|
||||
# 上传根目录(与 routers/upload.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖)
|
||||
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
|
||||
# 允许清理的上传子目录白名单(严格限定,防止路径穿越)
|
||||
UPLOAD_SUBDIRS = {"avatar", "article", "project"}
|
||||
|
||||
|
||||
def _resolve_upload_file(subdir: str, filename: str) -> Optional[Path]:
|
||||
"""把上传子目录与文件名解析为安全路径;子目录不在白名单或文件名含路径分隔符时返回 None。"""
|
||||
if subdir not in UPLOAD_SUBDIRS:
|
||||
return None
|
||||
if not filename or "/" in filename or "\\" in filename or ".." in filename:
|
||||
return None
|
||||
target = (UPLOADS_ROOT / subdir / filename).resolve()
|
||||
root = UPLOADS_ROOT.resolve()
|
||||
if root not in target.parents:
|
||||
return None
|
||||
return target
|
||||
|
||||
|
||||
def _collect_upload_paths(article: Article) -> list:
|
||||
"""收集文章关联的上传文件路径:封面 + 正文 Markdown 图片(仅站内 /uploads/ 路径)。"""
|
||||
paths = []
|
||||
seen = set()
|
||||
|
||||
def add_if_safe(subdir: str, filename: str) -> None:
|
||||
if not filename or filename in seen:
|
||||
return
|
||||
path = _resolve_upload_file(subdir, filename)
|
||||
if path is not None:
|
||||
seen.add(filename)
|
||||
paths.append(path)
|
||||
|
||||
def parse_url(url: str) -> None:
|
||||
# 仅处理站内路径:/uploads/<子目录>/<随机文件名>
|
||||
parts = (url or "").split("/")
|
||||
if len(parts) == 4 and parts[1] == "uploads":
|
||||
add_if_safe(parts[2], parts[3])
|
||||
|
||||
parse_url(article.cover or "")
|
||||
# 同时解析正文图片 ![]() 与视频 @[视频]() 两种站内资源引用
|
||||
for match in re.finditer(r"(?:!\[[^\]]*\]|@\[[^\]]*\]|\[[^\]]*\])\(([^)\s]+)\)", article.content or ""):
|
||||
parse_url(match.group(1).strip())
|
||||
return paths
|
||||
|
||||
|
||||
def _delete_upload_files(paths: list) -> None:
|
||||
"""尽力删除文件:文件不存在或删除失败都不影响文章删除结果(不阻断主流程)。"""
|
||||
for path in paths:
|
||||
try:
|
||||
path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
# 文件被占用 / 权限不足等场景:跳过,避免文章删除失败
|
||||
pass
|
||||
|
||||
|
||||
def _check_visibility(visibility: str) -> None:
|
||||
"""校验文章可见性取值。"""
|
||||
if visibility not in (VISIBILITY_PUBLIC, VISIBILITY_FRIEND):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="visibility 只能为 public 或 friend")
|
||||
|
||||
|
||||
def _check_category(category: str) -> None:
|
||||
"""校验文章分区取值(life 生活 / study 学习)。"""
|
||||
if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="category 只能为 life 或 study")
|
||||
|
||||
|
||||
def _require_blogger(user: User) -> None:
|
||||
"""校验当前用户是否为博主。"""
|
||||
if user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以操作文章")
|
||||
|
||||
|
||||
@router.post("/add", response_model=UnifiedResponse)
|
||||
def add_article(
|
||||
payload: ArticleCreate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""发布文章:仅博主可操作,支持封面与 public / friend 两种可见性。"""
|
||||
_require_blogger(current_user)
|
||||
_check_visibility(payload.visibility)
|
||||
category = payload.category or ARTICLE_CATEGORY_LIFE
|
||||
_check_category(category)
|
||||
|
||||
title = payload.title.strip()
|
||||
content = payload.content.strip()
|
||||
cover = (payload.cover or "").strip() or None
|
||||
if not title:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章标题不能为空")
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章内容不能为空")
|
||||
|
||||
article = Article(
|
||||
title=title,
|
||||
content=content,
|
||||
cover=cover,
|
||||
visibility=payload.visibility,
|
||||
category=category,
|
||||
author_id=current_user.id,
|
||||
)
|
||||
db.add(article)
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
|
||||
data = {
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"cover": article.cover,
|
||||
"visibility": article.visibility,
|
||||
"category": article.category or ARTICLE_CATEGORY_LIFE,
|
||||
"created_time": article.created_time,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="发布成功")
|
||||
|
||||
|
||||
@router.get("/list", response_model=UnifiedResponse)
|
||||
def list_articles(
|
||||
page: int = Query(1, ge=1, description="页码,从 1 开始"),
|
||||
page_size: int = Query(10, ge=1, le=100, description="每页数量"),
|
||||
category: Optional[str] = Query(None, description="分区过滤:life / study(不传返回全部分区)"),
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""文章列表(分页):公开文章所有人可见,好友文章对游客仅展示标题与封面。"""
|
||||
privileged = can_read_friend_article(current_user)
|
||||
|
||||
query = db.query(Article).order_by(Article.created_time.desc(), Article.id.desc())
|
||||
if category:
|
||||
_check_category(category)
|
||||
if category == ARTICLE_CATEGORY_LIFE:
|
||||
# 旧数据 category 为空视为生活区
|
||||
query = query.filter(or_(Article.category == category, Article.category.is_(None)))
|
||||
else:
|
||||
query = query.filter(Article.category == category)
|
||||
total = query.count()
|
||||
articles = query.offset((page - 1) * page_size).limit(page_size).all()
|
||||
|
||||
items = []
|
||||
for a in articles:
|
||||
if a.visibility == VISIBILITY_FRIEND and not privileged:
|
||||
# 游客/匿名用户:好友文章仅展示标题与封面
|
||||
items.append({
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"cover": a.cover,
|
||||
"visibility": a.visibility,
|
||||
"category": a.category or ARTICLE_CATEGORY_LIFE,
|
||||
})
|
||||
continue
|
||||
items.append({
|
||||
"id": a.id,
|
||||
"title": a.title,
|
||||
"cover": a.cover,
|
||||
"visibility": a.visibility,
|
||||
"category": a.category or ARTICLE_CATEGORY_LIFE,
|
||||
"author_id": a.author_id,
|
||||
"author_username": a.author.username if a.author else None,
|
||||
"created_time": a.created_time,
|
||||
})
|
||||
|
||||
data = {
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.get("/{article_id}", response_model=UnifiedResponse)
|
||||
def get_article(
|
||||
article_id: int,
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""文章详情:好友文章对游客仅返回标题与封面,不返回正文。"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
|
||||
if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
|
||||
data = {
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"cover": article.cover,
|
||||
"visibility": article.visibility,
|
||||
"category": article.category or ARTICLE_CATEGORY_LIFE,
|
||||
"content": None,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="该文章仅好友可见,正文不可查看")
|
||||
|
||||
data = {
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"cover": article.cover,
|
||||
"content": article.content,
|
||||
"visibility": article.visibility,
|
||||
"category": article.category or ARTICLE_CATEGORY_LIFE,
|
||||
"author_id": article.author_id,
|
||||
"author_username": article.author.username if article.author else None,
|
||||
"created_time": article.created_time,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.put("/{article_id}", response_model=UnifiedResponse)
|
||||
def update_article(
|
||||
article_id: int,
|
||||
payload: ArticleUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""更新文章:仅博主可操作;仅更新传入的字段,未传字段保持不变。"""
|
||||
_require_blogger(current_user)
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
|
||||
if payload.visibility is not None:
|
||||
_check_visibility(payload.visibility)
|
||||
article.visibility = payload.visibility
|
||||
if payload.category is not None:
|
||||
_check_category(payload.category)
|
||||
article.category = payload.category
|
||||
if payload.title is not None:
|
||||
title = payload.title.strip()
|
||||
if not title:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章标题不能为空")
|
||||
article.title = title
|
||||
if payload.content is not None:
|
||||
content = payload.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文章内容不能为空")
|
||||
article.content = content
|
||||
if payload.cover is not None:
|
||||
article.cover = payload.cover.strip() or None
|
||||
|
||||
db.commit()
|
||||
db.refresh(article)
|
||||
data = {
|
||||
"id": article.id,
|
||||
"title": article.title,
|
||||
"cover": article.cover,
|
||||
"visibility": article.visibility,
|
||||
"category": article.category or ARTICLE_CATEGORY_LIFE,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="更新成功")
|
||||
|
||||
|
||||
@router.delete("/{article_id}", response_model=UnifiedResponse)
|
||||
def delete_article(
|
||||
article_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""删除文章:仅博主可操作;关联评论与点赞随文章级联删除。"""
|
||||
_require_blogger(current_user)
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
|
||||
# 删除前收集关联的上传文件(封面与正文图片),防止删除文章后留下孤儿文件
|
||||
upload_paths = _collect_upload_paths(article)
|
||||
|
||||
db.delete(article)
|
||||
db.commit()
|
||||
|
||||
# 数据删除成功后再清理文件(失败不阻断,避免因文件权限问题导致文章无法删除)
|
||||
_delete_upload_files(upload_paths)
|
||||
return UnifiedResponse(success=True, data={"id": article_id}, message="删除成功")
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
评论路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/comment/add 发表评论或回复(仅好友/博主;content 最长 2000 字;parent_id 指定回复对象)
|
||||
- GET /api/comment/list 评论列表(可见性与文章一致;按时间升序,返回 parent_id 供前端分组展示)
|
||||
|
||||
权限:
|
||||
- 游客不可评论;visitor 角色不可评论(需先成为好友)。
|
||||
- 好友文章仅好友/博主可查看评论。
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
ROLE_BLOGGER,
|
||||
ROLE_FRIEND,
|
||||
VISIBILITY_FRIEND,
|
||||
Article,
|
||||
Comment,
|
||||
User,
|
||||
)
|
||||
from ..schemas import CommentCreate, UnifiedResponse
|
||||
from ..security import action_limiter, get_client_ip
|
||||
from .deps import can_read_friend_article, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/comment", tags=["comment"])
|
||||
|
||||
# 评论内容长度限制(可通过 .env 的 COMMENT_MAX_LENGTH 调整,默认 2000)
|
||||
COMMENT_MAX_LENGTH = int(os.getenv("COMMENT_MAX_LENGTH") or "2000")
|
||||
|
||||
|
||||
@router.post("/add", response_model=UnifiedResponse)
|
||||
def add_comment(
|
||||
payload: CommentCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""发表评论或回复:仅好友或博主可评论,回复时 parent_id 必须属于同一篇文章。"""
|
||||
# 写操作限流(IP 维度):防止好友账号刷屏
|
||||
if action_limiter.is_blocked(get_client_ip(request)):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="操作过于频繁,请稍后再试")
|
||||
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有好友或博主可以评论")
|
||||
article = db.query(Article).filter(Article.id == payload.article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
|
||||
content = payload.content.strip()
|
||||
if not content:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="评论内容不能为空")
|
||||
if len(content) > COMMENT_MAX_LENGTH:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"评论内容不能超过 {COMMENT_MAX_LENGTH} 字")
|
||||
|
||||
# 回复校验:父评论必须存在且属于同一篇文章,且不允许回复自身(无自身场景,防脏数据)
|
||||
parent_id = payload.parent_id
|
||||
if parent_id is not None:
|
||||
parent = db.query(Comment).filter(Comment.id == parent_id).first()
|
||||
if parent is None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复的评论不存在")
|
||||
if parent.article_id != article.id:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="回复的评论不属于该文章")
|
||||
|
||||
comment = Comment(
|
||||
article_id=payload.article_id,
|
||||
user_id=current_user.id,
|
||||
parent_id=parent_id,
|
||||
content=content,
|
||||
)
|
||||
db.add(comment)
|
||||
db.commit()
|
||||
db.refresh(comment)
|
||||
action_limiter.hit(get_client_ip(request))
|
||||
|
||||
data = {
|
||||
"id": comment.id,
|
||||
"article_id": comment.article_id,
|
||||
"user_id": comment.user_id,
|
||||
"parent_id": comment.parent_id,
|
||||
"username": current_user.username,
|
||||
"avatar": current_user.avatar,
|
||||
"content": comment.content,
|
||||
"created_time": comment.created_time,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="评论成功")
|
||||
|
||||
|
||||
@router.get("/list", response_model=UnifiedResponse)
|
||||
def list_comments(
|
||||
article_id: int,
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""评论列表:公开文章所有人可见,好友文章仅好友/博主可见。"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该文章评论")
|
||||
|
||||
comments = (
|
||||
db.query(Comment)
|
||||
.filter(Comment.article_id == article_id)
|
||||
.order_by(Comment.created_time.asc(), Comment.id.asc())
|
||||
.all()
|
||||
)
|
||||
data = [
|
||||
{
|
||||
"id": c.id,
|
||||
"article_id": c.article_id,
|
||||
"user_id": c.user_id,
|
||||
"parent_id": c.parent_id,
|
||||
"username": c.user.username if c.user else None,
|
||||
"avatar": c.user.avatar if c.user else None,
|
||||
"content": c.content,
|
||||
"created_time": c.created_time,
|
||||
}
|
||||
for c in comments
|
||||
]
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
@@ -0,0 +1,41 @@
|
||||
"""
|
||||
路由公共依赖。
|
||||
|
||||
提供“可选登录”依赖与权限判断工具:
|
||||
- 文章可见性控制需要区分“游客”和“登录用户”
|
||||
- 携带有效令牌时返回用户,未携带令牌时返回 None
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import HTTPAuthorizationCredentials
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import bearer_scheme, decode_token
|
||||
from ..database import get_db
|
||||
from ..models import ROLE_BLOGGER, ROLE_FRIEND, User
|
||||
|
||||
|
||||
def get_optional_user(
|
||||
credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme),
|
||||
db: Session = Depends(get_db),
|
||||
) -> Optional[User]:
|
||||
"""可选登录依赖:未携带令牌返回 None;令牌无效返回 401;否则返回当前用户。"""
|
||||
if credentials is None:
|
||||
return None
|
||||
payload = decode_token(credentials.credentials)
|
||||
try:
|
||||
user_id = int(payload.get("sub"))
|
||||
except (TypeError, ValueError):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="令牌无效")
|
||||
user = db.query(User).filter(User.id == user_id).first()
|
||||
# 令牌版本校验:与 get_current_user 保持一致,密码重置后旧令牌失效
|
||||
if user is not None and (payload.get("ver") or 0) != (user.token_version or 0):
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
|
||||
return user
|
||||
|
||||
|
||||
def can_read_friend_article(user: Optional[User]) -> bool:
|
||||
"""判断用户是否具备查看好友文章的权限(friend / blogger)。"""
|
||||
return user is not None and user.role in (ROLE_FRIEND, ROLE_BLOGGER)
|
||||
@@ -0,0 +1,161 @@
|
||||
"""
|
||||
邮箱验证码路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/email/send-code 向邮箱发送验证码(10 分钟有效,60 秒内不可重复发送;
|
||||
另有按邮箱/按 IP 的小时限流,防止被当作垃圾邮件中继)
|
||||
- POST /api/email/verify-code 校验验证码(校验成功后即作废,一次性使用;
|
||||
同一邮箱尝试超过 5 次自动作废验证码并要求重发)
|
||||
|
||||
注意:
|
||||
- issue_code / verify_code 被密码重置路由复用,限流逻辑集中在两个函数内。
|
||||
"""
|
||||
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from datetime import timedelta
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..database import get_db
|
||||
from ..email import send_verification_code
|
||||
from ..models import PURPOSE_REGISTER, PURPOSE_RESET, EmailCode, utcnow
|
||||
from ..schemas import SendCodeRequest, UnifiedResponse, VerifyCodeRequest
|
||||
from ..security import (
|
||||
get_client_ip,
|
||||
is_valid_email,
|
||||
send_code_email_limiter,
|
||||
send_code_ip_limiter,
|
||||
verify_code_limiter,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/email", tags=["email"])
|
||||
|
||||
# 验证码有效期与同一邮箱的重发间隔(可通过 .env 调整)
|
||||
CODE_TTL_MINUTES = int(os.getenv("EMAIL_CODE_TTL_MINUTES") or "10")
|
||||
RESEND_INTERVAL_SECONDS = int(os.getenv("EMAIL_RESEND_INTERVAL_SECONDS") or "60")
|
||||
|
||||
|
||||
def issue_code(email: str, purpose: str, db: Session, request: Optional[Request] = None) -> None:
|
||||
"""生成验证码并发送邮件(send-code 与忘记密码复用);发送失败回滚并抛错。"""
|
||||
now = utcnow()
|
||||
|
||||
# 限流:同一邮箱每小时最多 5 封;若带请求对象,再按 IP 每小时最多 10 封
|
||||
if send_code_email_limiter.is_blocked(email):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="该邮箱发送过于频繁,请稍后再试")
|
||||
if request is not None:
|
||||
client_ip = get_client_ip(request)
|
||||
if send_code_ip_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="发送过于频繁,请稍后再试")
|
||||
|
||||
latest = (
|
||||
db.query(EmailCode)
|
||||
.filter(EmailCode.email == email, EmailCode.purpose == purpose)
|
||||
.order_by(EmailCode.created_time.desc())
|
||||
.first()
|
||||
)
|
||||
if latest is not None and latest.created_time > now - timedelta(seconds=RESEND_INTERVAL_SECONDS):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="发送过于频繁,请稍后再试")
|
||||
|
||||
code = f"{secrets.randbelow(1000000):06d}"
|
||||
# 作废该邮箱同用途的历史验证码,只保留最新一条
|
||||
db.query(EmailCode).filter(
|
||||
EmailCode.email == email,
|
||||
EmailCode.purpose == purpose,
|
||||
).update({EmailCode.used: True})
|
||||
|
||||
record = EmailCode(
|
||||
email=email,
|
||||
code=code,
|
||||
purpose=purpose,
|
||||
expires_at=now + timedelta(minutes=CODE_TTL_MINUTES),
|
||||
)
|
||||
db.add(record)
|
||||
db.flush()
|
||||
try:
|
||||
send_verification_code(email, code)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="验证码发送失败,请稍后再试")
|
||||
db.commit()
|
||||
|
||||
# 发送成功:记录限流次数,并重置该校验尝试计数(新验证码重新计数)
|
||||
send_code_email_limiter.hit(email)
|
||||
if request is not None:
|
||||
send_code_ip_limiter.hit(get_client_ip(request))
|
||||
verify_code_limiter.reset(email)
|
||||
|
||||
|
||||
def verify_code(payload: VerifyCodeRequest, db: Session) -> None:
|
||||
"""校验验证码:正确则标记为已使用(一次性);失败过多则作废验证码。"""
|
||||
email = payload.email.strip().lower()
|
||||
now = utcnow()
|
||||
|
||||
# 尝试限流:同一邮箱尝试超过 5 次即作废当前验证码,必须重新发送
|
||||
if verify_code_limiter.is_blocked(email):
|
||||
db.query(EmailCode).filter(
|
||||
EmailCode.email == email,
|
||||
EmailCode.purpose == payload.purpose,
|
||||
EmailCode.used.is_(False),
|
||||
).update({EmailCode.used: True})
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="验证码尝试次数过多,请重新发送",
|
||||
)
|
||||
|
||||
record = (
|
||||
db.query(EmailCode)
|
||||
.filter(
|
||||
EmailCode.email == email,
|
||||
EmailCode.purpose == payload.purpose,
|
||||
EmailCode.used.is_(False),
|
||||
EmailCode.expires_at > now,
|
||||
)
|
||||
.order_by(EmailCode.created_time.desc())
|
||||
.first()
|
||||
)
|
||||
# 恒定时间比较,避免通过响应时间差枚举验证码
|
||||
if record is None or not hmac.compare_digest(record.code, payload.code):
|
||||
verify_code_limiter.hit(email)
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误或已过期")
|
||||
|
||||
record.used = True
|
||||
verify_code_limiter.reset(email)
|
||||
db.commit()
|
||||
|
||||
|
||||
@router.post("/send-code", response_model=UnifiedResponse)
|
||||
def send_code(
|
||||
payload: SendCodeRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""向指定邮箱发送验证码(带邮箱与 IP 双重限流)。"""
|
||||
email = payload.email.strip().lower()
|
||||
if not is_valid_email(email):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
|
||||
if payload.purpose not in (PURPOSE_REGISTER, PURPOSE_RESET):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用途不合法")
|
||||
|
||||
issue_code(email, payload.purpose, db, request)
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"email": email, "purpose": payload.purpose},
|
||||
message="验证码已发送,请查收邮件",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/verify-code", response_model=UnifiedResponse)
|
||||
def verify_code_route(payload: VerifyCodeRequest, db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""校验验证码:正确则标记为已使用(一次性)。"""
|
||||
email = payload.email.strip().lower()
|
||||
verify_code(payload, db)
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"email": email, "purpose": payload.purpose},
|
||||
message="验证码校验通过",
|
||||
)
|
||||
@@ -0,0 +1,191 @@
|
||||
"""
|
||||
好友路由。
|
||||
|
||||
按“用户向博主申请、博主审批”实现(Friend.user_id 为申请人):
|
||||
- POST /api/friend/apply 申请好友(登录用户;博主与已是好友的用户不可申请)
|
||||
- GET /api/friend/status 查询当前用户的好友状态(none / pending / accepted / rejected)
|
||||
- GET /api/friend/applications 好友申请列表(仅博主)
|
||||
- POST /api/friend/{friend_id}/approve 审批通过(仅博主):申请人角色提升为 friend
|
||||
- POST /api/friend/{friend_id}/reject 审批拒绝(仅博主)
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
FRIEND_STATUS_ACCEPTED,
|
||||
FRIEND_STATUS_PENDING,
|
||||
FRIEND_STATUS_REJECTED,
|
||||
ROLE_BLOGGER,
|
||||
ROLE_FRIEND,
|
||||
Friend,
|
||||
User,
|
||||
)
|
||||
from ..schemas import UnifiedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/friend", tags=["friend"])
|
||||
|
||||
|
||||
@router.post("/apply", response_model=UnifiedResponse)
|
||||
def apply_friend(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""申请好友:注册用户向博主发起好友申请。"""
|
||||
if current_user.role == ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="博主无需申请好友")
|
||||
if current_user.role == ROLE_FRIEND:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="你们已经是好友")
|
||||
|
||||
record = db.query(Friend).filter(Friend.user_id == current_user.id).first()
|
||||
if record is None:
|
||||
# 首次申请:新建待审批记录
|
||||
record = Friend(user_id=current_user.id, status=FRIEND_STATUS_PENDING)
|
||||
db.add(record)
|
||||
elif record.status == FRIEND_STATUS_PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="申请已提交,请等待博主审批")
|
||||
elif record.status == FRIEND_STATUS_ACCEPTED:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="你们已经是好友")
|
||||
else:
|
||||
# 此前被拒绝:允许重新提交申请
|
||||
record.status = FRIEND_STATUS_PENDING
|
||||
db.commit()
|
||||
db.refresh(record)
|
||||
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"id": record.id, "status": record.status},
|
||||
message="申请成功,等待博主审批",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/status", response_model=UnifiedResponse)
|
||||
def get_friend_status(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""查询当前用户的好友状态:none / pending / accepted / rejected(博主返回 blogger)。"""
|
||||
if current_user.role == ROLE_BLOGGER:
|
||||
return UnifiedResponse(success=True, data={"status": "blogger"}, message="获取成功")
|
||||
if current_user.role == ROLE_FRIEND:
|
||||
return UnifiedResponse(success=True, data={"status": "accepted"}, message="获取成功")
|
||||
|
||||
record = db.query(Friend).filter(Friend.user_id == current_user.id).first()
|
||||
status_value = record.status if record is not None else "none"
|
||||
return UnifiedResponse(success=True, data={"status": status_value}, message="获取成功")
|
||||
|
||||
|
||||
@router.get("/list", response_model=UnifiedResponse)
|
||||
def list_friends(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""好友列表:博主查看全部已成为好友的用户;好友用户查看自己与博主的好友关系。"""
|
||||
if current_user.role not in (ROLE_BLOGGER, ROLE_FRIEND):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="登录并成为好友后可查看好友列表")
|
||||
|
||||
if current_user.role == ROLE_BLOGGER:
|
||||
# 博主视角:所有已通过的好友申请,申请人即好友
|
||||
records = db.query(Friend).filter(Friend.status == FRIEND_STATUS_ACCEPTED).all()
|
||||
else:
|
||||
# 好友视角:自己那条已通过的申请,对应好友为博主
|
||||
records = db.query(Friend).filter(
|
||||
Friend.user_id == current_user.id,
|
||||
Friend.status == FRIEND_STATUS_ACCEPTED,
|
||||
).all()
|
||||
|
||||
data = []
|
||||
for record in records:
|
||||
member = (
|
||||
db.query(User).filter(User.id == record.user_id).first()
|
||||
if current_user.role == ROLE_BLOGGER
|
||||
else db.query(User).filter(User.role == ROLE_BLOGGER).first()
|
||||
)
|
||||
if member is None:
|
||||
continue
|
||||
data.append({
|
||||
"id": member.id,
|
||||
"username": member.username,
|
||||
"avatar": member.avatar,
|
||||
"bio": member.bio,
|
||||
"friend_since": record.created_time,
|
||||
})
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.get("/applications", response_model=UnifiedResponse)
|
||||
def list_applications(
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""好友申请列表:仅博主可查看全部申请记录。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以查看好友申请")
|
||||
records = db.query(Friend).order_by(Friend.id.asc()).all()
|
||||
data = [
|
||||
{
|
||||
"id": r.id,
|
||||
"user_id": r.user_id,
|
||||
"username": r.user.username if r.user else None,
|
||||
"email": r.user.email if r.user else None,
|
||||
"status": r.status,
|
||||
"created_time": r.created_time,
|
||||
}
|
||||
for r in records
|
||||
]
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.post("/{friend_id}/approve", response_model=UnifiedResponse)
|
||||
def approve_friend(
|
||||
friend_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""审批通过好友申请:仅博主;通过后申请人角色提升为 friend。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以审批好友申请")
|
||||
record = db.query(Friend).filter(Friend.id == friend_id).first()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="好友申请不存在")
|
||||
if record.status != FRIEND_STATUS_PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该申请已处理,不能重复审批")
|
||||
|
||||
applicant = db.query(User).filter(User.id == record.user_id).first()
|
||||
record.status = FRIEND_STATUS_ACCEPTED
|
||||
if applicant is not None and applicant.role != ROLE_BLOGGER:
|
||||
applicant.role = ROLE_FRIEND
|
||||
db.commit()
|
||||
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"id": record.id, "status": record.status},
|
||||
message="已通过好友申请",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{friend_id}/reject", response_model=UnifiedResponse)
|
||||
def reject_friend(
|
||||
friend_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""拒绝好友申请:仅博主。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以审批好友申请")
|
||||
record = db.query(Friend).filter(Friend.id == friend_id).first()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="好友申请不存在")
|
||||
if record.status != FRIEND_STATUS_PENDING:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="该申请已处理,不能重复操作")
|
||||
|
||||
record.status = FRIEND_STATUS_REJECTED
|
||||
db.commit()
|
||||
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"id": record.id, "status": record.status},
|
||||
message="已拒绝好友申请",
|
||||
)
|
||||
@@ -0,0 +1,264 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
SSH 白名单自动更新路由(阿里云安全组)。
|
||||
|
||||
背景:家庭宽带公网 IP 经常变化,安全组 22 端口若只放行固定 IP,
|
||||
换 IP 后就会把自己挡在门外。本路由让家庭电脑定时上报当前公网 IP,
|
||||
服务器调用阿里云 ECS API 自动更新安全组 22 端口的入方向白名单。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/ipwatch/report 上报当前公网 IP,自动增删安全组 22 端口规则
|
||||
- GET /api/ipwatch/status 查看服务配置状态(不含任何密钥)
|
||||
|
||||
安全设计:
|
||||
- AccessKey 只存在于服务器 .env,家庭端只持有 IPWATCH_SECRET 上报密钥
|
||||
- 上报密钥用 hmac 恒定时间比较,并按来源 IP 限流(默认 60 秒一次)
|
||||
- 更新顺序“先加新规则、后删旧规则”,任何一步失败都不会让 SSH 完全断连
|
||||
- 只操作“tcp 22/22 且来源为单个 IP(/32)”的规则,绝不碰 0.0.0.0/0 等其它规则
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..schemas import UnifiedResponse
|
||||
from ..security import RateLimiter, get_client_ip
|
||||
|
||||
router = APIRouter(prefix="/api/ipwatch", tags=["ipwatch"])
|
||||
|
||||
# 日志走 uvicorn 的 logger,方便 journalctl 查看
|
||||
logger = logging.getLogger("uvicorn.error")
|
||||
|
||||
|
||||
# ---------- 环境变量配置(服务器 .env,见 .env.example 中文注释) ----------
|
||||
|
||||
IPWATCH_SECRET = (os.getenv("IPWATCH_SECRET") or "").strip()
|
||||
ALIYUN_AK_ID = (os.getenv("ALIYUN_AK_ID") or "").strip()
|
||||
ALIYUN_AK_SECRET = (os.getenv("ALIYUN_AK_SECRET") or "").strip()
|
||||
SECURITY_GROUP_ID = (os.getenv("IPWATCH_SECURITY_GROUP_ID") or "").strip()
|
||||
IPWATCH_REGION = (os.getenv("IPWATCH_REGION") or "cn-hangzhou").strip()
|
||||
IPWATCH_PORT = int(os.getenv("IPWATCH_PORT") or "22")
|
||||
|
||||
# 同一来源 IP 两次上报的最小间隔(秒),防止密钥泄露后被刷白名单
|
||||
try:
|
||||
_report_interval = int(os.getenv("IPWATCH_REPORT_INTERVAL_SECONDS") or "60")
|
||||
except ValueError:
|
||||
_report_interval = 60
|
||||
report_limiter = RateLimiter(1, max(_report_interval, 1))
|
||||
|
||||
|
||||
class IpWatchReport(BaseModel):
|
||||
"""上报请求体:家庭端上传密钥与当前公网 IP。"""
|
||||
|
||||
secret: str = Field(min_length=1, max_length=256)
|
||||
ip: str = Field(min_length=7, max_length=45)
|
||||
|
||||
|
||||
def _aliyun_call(action: str, params: dict) -> dict:
|
||||
"""调用阿里云 ECS RPC API(HMAC-SHA1 签名,仅用 Python 标准库)。
|
||||
|
||||
Aliyun OpenAPI 签名规则:对全部参数按 key 排序后拼接,
|
||||
再用 AccessKeySecret 做 HMAC-SHA1,最后 BASE64 得到 Signature。
|
||||
"""
|
||||
query = {
|
||||
"AccessKeyId": ALIYUN_AK_ID,
|
||||
"Action": action,
|
||||
"Format": "JSON",
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureNonce": uuid.uuid4().hex, # 每次请求唯一,防止重放
|
||||
"SignatureVersion": "1.0",
|
||||
"Timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"Version": "2014-05-26",
|
||||
"RegionId": IPWATCH_REGION,
|
||||
}
|
||||
query.update(params or {})
|
||||
|
||||
def _enc(value: str) -> str:
|
||||
"""RFC3986 百分号编码(阿里云要求保留 -_.~ 三个字符)。"""
|
||||
return urllib.parse.quote(str(value), safe="-_.~")
|
||||
|
||||
canonical = "&".join(f"{_enc(k)}={_enc(v)}" for k, v in sorted(query.items()))
|
||||
string_to_sign = "GET&%2F&" + _enc(canonical)
|
||||
signature = base64.b64encode(
|
||||
hmac.new((ALIYUN_AK_SECRET + "&").encode(), string_to_sign.encode(), hashlib.sha1).digest()
|
||||
).decode()
|
||||
url = f"https://ecs.{IPWATCH_REGION}.aliyuncs.com/?{canonical}&Signature={_enc(signature)}"
|
||||
|
||||
request = urllib.request.Request(url, headers={"User-Agent": "MyBlog-ipwatch/1.0"})
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
# 阿里云返回错误时,响应体里是 JSON 格式的错误信息
|
||||
try:
|
||||
return json.loads(exc.read().decode())
|
||||
except Exception:
|
||||
return {"Code": "HTTP_ERROR", "Message": f"阿里云接口返回 HTTP {exc.code}"}
|
||||
except Exception as exc:
|
||||
return {"Code": "NETWORK_ERROR", "Message": f"无法连接阿里云接口:{exc}"}
|
||||
|
||||
|
||||
def _fetch_ingress_rules() -> list:
|
||||
"""读取安全组全部入方向规则,失败时抛出 502。"""
|
||||
resp = _aliyun_call(
|
||||
"DescribeSecurityGroupAttribute",
|
||||
{"SecurityGroupId": SECURITY_GROUP_ID, "Direction": "ingress"},
|
||||
)
|
||||
if resp.get("Code"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"读取安全组规则失败:{resp.get('Message') or resp.get('Code')}",
|
||||
)
|
||||
return resp.get("Permissions", {}).get("Permission", [])
|
||||
|
||||
|
||||
def _host_ip(cidr: str) -> str:
|
||||
"""把安全组来源归一化为纯 IP;非 IPv4 单 IP(/32) 返回空字符串。
|
||||
|
||||
阿里云返回的 SourceCidrIp 可能是 "1.2.3.4" 或 "1.2.3.4/32",
|
||||
本函数只认 IPv4 的 /32 单 IP,其它(0.0.0.0/0、IPv6 等)一律忽略,
|
||||
防止误删用户手动配置的放行规则。
|
||||
"""
|
||||
if not cidr:
|
||||
return ""
|
||||
value = cidr.strip()
|
||||
if "/" not in value:
|
||||
try:
|
||||
ip = ipaddress.ip_address(value)
|
||||
except ValueError:
|
||||
return ""
|
||||
return str(ip) if ip.version == 4 else ""
|
||||
try:
|
||||
network = ipaddress.ip_network(value, strict=False)
|
||||
except ValueError:
|
||||
return ""
|
||||
if network.version != 4 or network.prefixlen != 32:
|
||||
return ""
|
||||
return str(network.network_address)
|
||||
|
||||
|
||||
@router.get("/status", response_model=UnifiedResponse)
|
||||
def status_info() -> UnifiedResponse:
|
||||
"""查看服务配置状态(不返回任何密钥),便于部署后排查。"""
|
||||
data = {
|
||||
"configured": bool(IPWATCH_SECRET and ALIYUN_AK_ID and ALIYUN_AK_SECRET and SECURITY_GROUP_ID),
|
||||
"region": IPWATCH_REGION,
|
||||
"security_group_id": SECURITY_GROUP_ID,
|
||||
"port": IPWATCH_PORT,
|
||||
"report_interval_seconds": report_limiter.window_seconds,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.post("/report", response_model=UnifiedResponse)
|
||||
def report(payload: IpWatchReport, request: Request) -> UnifiedResponse:
|
||||
"""上报当前公网 IP:先加新白名单、再删旧白名单,全程不会锁死 SSH。"""
|
||||
# 1. 服务配置检查(未配置时直接拒绝,避免密钥为空被绕过)
|
||||
if not IPWATCH_SECRET:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="IP 白名单服务未配置")
|
||||
if not (ALIYUN_AK_ID and ALIYUN_AK_SECRET):
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="阿里云密钥未配置")
|
||||
if not SECURITY_GROUP_ID:
|
||||
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="安全组未配置")
|
||||
|
||||
# 2. 上报密钥校验(恒定时间比较,防止时序侧信道)
|
||||
if not hmac.compare_digest(payload.secret.encode("utf-8"), IPWATCH_SECRET.encode("utf-8")):
|
||||
logger.warning("ipwatch: 上报密钥错误,来源 %s", get_client_ip(request))
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="上报密钥错误")
|
||||
|
||||
# 3. 按来源 IP 限流,防止密钥泄露后被无限刷白名单
|
||||
client_ip = get_client_ip(request)
|
||||
if report_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="上报过于频繁,请稍后再试")
|
||||
|
||||
# 4. IP 格式校验(仅支持 IPv4)
|
||||
ip_text = payload.ip.strip()
|
||||
try:
|
||||
ip_obj = ipaddress.ip_address(ip_text)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="IP 地址格式不正确")
|
||||
if ip_obj.version != 4:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 IPv4 地址")
|
||||
new_ip = str(ip_obj)
|
||||
new_cidr = f"{new_ip}/32"
|
||||
target_range = f"{IPWATCH_PORT}/{IPWATCH_PORT}"
|
||||
|
||||
# 5. 读取当前 22 端口规则,判断是否需要更新
|
||||
rules = _fetch_ingress_rules()
|
||||
port_rules = [
|
||||
r
|
||||
for r in rules
|
||||
if str(r.get("IpProtocol", "")).lower() == "tcp" and r.get("PortRange") == target_range
|
||||
]
|
||||
already_authorized = any(_host_ip(r.get("SourceCidrIp")) == new_ip for r in port_rules)
|
||||
if already_authorized:
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data={"changed": False, "ip": new_ip, "port": IPWATCH_PORT},
|
||||
message="IP 已在白名单中,无需更新",
|
||||
)
|
||||
|
||||
# 6. 先添加新 IP 规则(成功后才进入删除阶段,避免 SSH 断连)
|
||||
add_resp = _aliyun_call(
|
||||
"AuthorizeSecurityGroup",
|
||||
{
|
||||
"SecurityGroupId": SECURITY_GROUP_ID,
|
||||
"IpProtocol": "tcp",
|
||||
"PortRange": target_range,
|
||||
"SourceCidrIp": new_cidr,
|
||||
"Policy": "accept",
|
||||
"Description": "MyBlog IPWatch 自动白名单",
|
||||
},
|
||||
)
|
||||
if add_resp.get("Code"):
|
||||
logger.error("ipwatch: 添加白名单失败 %s -> %s", new_cidr, add_resp)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"添加白名单失败:{add_resp.get('Message') or add_resp.get('Code')}",
|
||||
)
|
||||
|
||||
# 7. 删除旧的单 IP 规则(只删 22 端口 /32 来源,绝不动 0.0.0.0/0 等规则)
|
||||
removed = []
|
||||
for rule in port_rules:
|
||||
old_ip = _host_ip(rule.get("SourceCidrIp"))
|
||||
if not old_ip or old_ip == new_ip:
|
||||
continue
|
||||
# 阿里云对旧规则的存储格式不统一(有的带 /32,有的不带),
|
||||
# 依次尝试两种格式;任一成功即视为删除完成
|
||||
revoke_ok = False
|
||||
for cidr_candidate in (f"{old_ip}/32", old_ip):
|
||||
revoke_resp = _aliyun_call(
|
||||
"RevokeSecurityGroup",
|
||||
{
|
||||
"SecurityGroupId": SECURITY_GROUP_ID,
|
||||
"IpProtocol": "tcp",
|
||||
"PortRange": target_range,
|
||||
"SourceCidrIp": cidr_candidate,
|
||||
"Policy": "accept",
|
||||
},
|
||||
)
|
||||
if not revoke_resp.get("Code"):
|
||||
revoke_ok = True
|
||||
break
|
||||
if revoke_ok:
|
||||
removed.append(old_ip)
|
||||
else:
|
||||
# 新规则已生效,旧规则删除失败只是残留,不影响 SSH 可用性
|
||||
logger.warning("ipwatch: 删除旧规则失败 %s -> %s", old_ip, revoke_resp)
|
||||
|
||||
report_limiter.hit(client_ip)
|
||||
data = {"changed": True, "ip": new_ip, "port": IPWATCH_PORT, "removed": removed}
|
||||
logger.info("ipwatch: 白名单更新完成 新IP=%s 删除=%s", new_ip, removed)
|
||||
return UnifiedResponse(success=True, data=data, message="白名单更新成功")
|
||||
@@ -0,0 +1,85 @@
|
||||
"""
|
||||
点赞路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/like/add 点赞(好友或博主,同一用户不能重复点赞)
|
||||
- GET /api/like/list 点赞列表(可见性与文章一致)
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import get_db
|
||||
from ..models import (
|
||||
ROLE_BLOGGER,
|
||||
ROLE_FRIEND,
|
||||
VISIBILITY_FRIEND,
|
||||
Article,
|
||||
Like,
|
||||
User,
|
||||
)
|
||||
from ..schemas import LikeCreate, UnifiedResponse
|
||||
from ..security import action_limiter, get_client_ip
|
||||
from .deps import can_read_friend_article, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/like", tags=["like"])
|
||||
|
||||
|
||||
@router.post("/add", response_model=UnifiedResponse)
|
||||
def add_like(
|
||||
payload: LikeCreate,
|
||||
request: Request,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""点赞:仅好友或博主可点赞,同一用户不能重复点赞。"""
|
||||
# 写操作限流(IP 维度):防止好友账号刷点赞
|
||||
if action_limiter.is_blocked(get_client_ip(request)):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="操作过于频繁,请稍后再试")
|
||||
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有好友或博主可以点赞")
|
||||
article = db.query(Article).filter(Article.id == payload.article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
exists = (
|
||||
db.query(Like)
|
||||
.filter(Like.article_id == payload.article_id, Like.user_id == current_user.id)
|
||||
.first()
|
||||
)
|
||||
if exists is not None:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能重复点赞")
|
||||
|
||||
like = Like(article_id=payload.article_id, user_id=current_user.id)
|
||||
db.add(like)
|
||||
db.commit()
|
||||
db.refresh(like)
|
||||
action_limiter.hit(get_client_ip(request))
|
||||
|
||||
data = {"id": like.id, "article_id": like.article_id, "user_id": like.user_id}
|
||||
return UnifiedResponse(success=True, data=data, message="点赞成功")
|
||||
|
||||
|
||||
@router.get("/list", response_model=UnifiedResponse)
|
||||
def list_likes(
|
||||
article_id: int,
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""点赞列表:公开文章所有人可见,好友文章仅好友/博主可见。"""
|
||||
article = db.query(Article).filter(Article.id == article_id).first()
|
||||
if article is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文章不存在")
|
||||
if article.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权查看该文章点赞")
|
||||
|
||||
likes = db.query(Like).filter(Like.article_id == article_id).all()
|
||||
# 不返回点赞用户名单(避免泄露用户名与账号关联,防枚举),
|
||||
# 仅返回点赞数量与当前登录用户是否已点赞
|
||||
data = {
|
||||
"count": len(likes),
|
||||
"includes_me": current_user is not None and any(l.user_id == current_user.id for l in likes),
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
@@ -0,0 +1,70 @@
|
||||
"""
|
||||
密码重置路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/password/forgot 向注册邮箱发送重置验证码(未注册邮箱也返回同样提示,避免泄露)
|
||||
- POST /api/password/reset 校验验证码并重置密码
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import hash_password
|
||||
from ..database import get_db
|
||||
from ..models import PURPOSE_RESET, User
|
||||
from ..schemas import (
|
||||
ForgotPasswordRequest,
|
||||
ResetPasswordRequest,
|
||||
UnifiedResponse,
|
||||
VerifyCodeRequest,
|
||||
)
|
||||
from ..security import is_valid_email
|
||||
from .email import issue_code, verify_code
|
||||
|
||||
router = APIRouter(prefix="/api/password", tags=["password"])
|
||||
|
||||
# 密码最小长度:与注册接口保持一致,可通过 .env 的 PASSWORD_MIN_LENGTH 调整(默认 6)
|
||||
PASSWORD_MIN_LENGTH = int(os.getenv("PASSWORD_MIN_LENGTH") or "6")
|
||||
|
||||
|
||||
@router.post("/forgot", response_model=UnifiedResponse)
|
||||
def forgot_password(
|
||||
payload: ForgotPasswordRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""忘记密码:向已注册邮箱发送重置验证码(限流逻辑与 send-code 一致)。"""
|
||||
email = payload.email.strip().lower()
|
||||
if not is_valid_email(email):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user is None:
|
||||
# 未注册邮箱也返回同样提示,避免泄露邮箱是否已注册
|
||||
return UnifiedResponse(success=True, data=None, message="如该邮箱已注册,验证码已发送")
|
||||
issue_code(email, PURPOSE_RESET, db, request)
|
||||
return UnifiedResponse(success=True, data=None, message="验证码已发送,请查收邮件")
|
||||
|
||||
|
||||
@router.post("/reset", response_model=UnifiedResponse)
|
||||
def reset_password(payload: ResetPasswordRequest, db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""重置密码:校验验证码后更新为新密码(验证码一次性使用,且有尝试次数限制)。"""
|
||||
email = payload.email.strip().lower()
|
||||
if len(payload.new_password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=f"新密码至少 {PASSWORD_MIN_LENGTH} 位")
|
||||
if len(payload.new_password) > 128:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="新密码不能超过 128 位")
|
||||
|
||||
# 校验验证码(通过后验证码作废,一次性使用;尝试次数过多时抛 429)
|
||||
verify_code(VerifyCodeRequest(email=email, code=payload.code, purpose=PURPOSE_RESET), db)
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
if user is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||||
|
||||
user.password_hash = hash_password(payload.new_password)
|
||||
# 自增令牌版本号:使该用户已签发的所有旧 JWT 立即失效(重置密码后需重新登录)
|
||||
user.token_version += 1
|
||||
db.commit()
|
||||
return UnifiedResponse(success=True, data=None, message="密码重置成功,请使用新密码登录")
|
||||
@@ -0,0 +1,412 @@
|
||||
"""
|
||||
项目路由(L0 静态托管)。
|
||||
|
||||
实现接口(统一响应格式 {success, data, message}):
|
||||
- GET /api/project/list 项目列表(公开)
|
||||
- GET /api/project/{id} 项目详情(公开)
|
||||
- POST /api/project/add 添加项目(仅博主;上传 zip 自动检测纯静态并部署在线演示)
|
||||
- PUT /api/project/{id} 更新项目(仅博主)
|
||||
- DELETE /api/project/{id} 删除项目(仅博主)
|
||||
|
||||
静态托管规则(L0):
|
||||
- zip 内含 index.html 且无后端/可执行文件 -> static:解压到 uploads/demos/{id}/,
|
||||
Nginx 通过 /demo/{id}/ 提供在线演示
|
||||
- 其他情况 -> link:必须填写 GitHub 链接,"在线运行"按钮跳转到该链接
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import tempfile
|
||||
import uuid
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import PROJECT_ROOT, get_db
|
||||
from ..models import (
|
||||
PROJECT_TYPE_LINK,
|
||||
PROJECT_TYPE_STATIC,
|
||||
ROLE_BLOGGER,
|
||||
VISIBILITY_FRIEND,
|
||||
VISIBILITY_PUBLIC,
|
||||
Project,
|
||||
User,
|
||||
)
|
||||
from ..schemas import ProjectOut, UnifiedResponse
|
||||
from .deps import can_read_friend_article, get_optional_user
|
||||
|
||||
router = APIRouter(prefix="/api/project", tags=["project"])
|
||||
|
||||
# 上传根目录(与 routers/upload.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖)
|
||||
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
|
||||
DEMOS_DIR = UPLOADS_ROOT / "demos"
|
||||
|
||||
# zip 大小上限:与项目文件上传一致(50MB)
|
||||
MAX_PROJECT_SIZE = 50 * 1024 * 1024
|
||||
|
||||
# 危险扩展名:出现任一文件即视为"非纯静态项目",不提供在线演示(防止服务器执行代码)
|
||||
DANGER_EXTS = {
|
||||
".py", ".pyc", ".pyd", ".php", ".phtml", ".rb", ".pl", ".pm", ".go",
|
||||
".java", ".jar", ".class", ".c", ".cpp", ".cc", ".h", ".hpp",
|
||||
".sh", ".bash", ".zsh", ".bat", ".cmd", ".ps1", ".vbs",
|
||||
".exe", ".dll", ".so", ".dylib", ".app", ".lua", ".asp", ".aspx",
|
||||
".jsp", ".cgi", ".swift", ".rs", ".cs", ".kt", ".scala",
|
||||
}
|
||||
|
||||
|
||||
def _require_blogger(user: User) -> None:
|
||||
"""校验当前用户是否为博主。"""
|
||||
if user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以管理项目")
|
||||
|
||||
|
||||
def _check_visibility(visibility: str) -> None:
|
||||
"""校验项目可见性取值。"""
|
||||
if visibility not in (VISIBILITY_PUBLIC, VISIBILITY_FRIEND):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="visibility 只能为 public 或 friend")
|
||||
|
||||
|
||||
def _safe_extract(zip_path: Path, dest: Path) -> None:
|
||||
"""安全解压 zip:拒绝路径穿越(zip slip)与绝对路径。"""
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
dest_root = dest.resolve()
|
||||
for member in zf.infolist():
|
||||
raw = member.filename.replace("\\", "/")
|
||||
# 拒绝绝对路径与向上穿越(..)
|
||||
if raw.startswith("/") or ".." in raw.split("/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="压缩包包含非法路径")
|
||||
target = (dest / raw).resolve()
|
||||
if not target.is_relative_to(dest_root):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="压缩包包含非法路径")
|
||||
if member.is_dir():
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
continue
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zf.open(member) as src, open(target, "wb") as out:
|
||||
shutil.copyfileobj(src, out)
|
||||
|
||||
|
||||
def _find_web_root(tmp: Path) -> Optional[Path]:
|
||||
"""查找静态网站根目录:zip 根目录或唯一的顶层文件夹中含 index.html。"""
|
||||
if (tmp / "index.html").is_file():
|
||||
return tmp
|
||||
tops = [p for p in tmp.iterdir()]
|
||||
if len(tops) == 1 and tops[0].is_dir() and (tops[0] / "index.html").is_file():
|
||||
return tops[0]
|
||||
return None
|
||||
|
||||
|
||||
def _scan_danger(tmp: Path) -> bool:
|
||||
"""扫描目录树中是否存在后端/可执行文件扩展名(不扫描解压产物之外)。"""
|
||||
for p in tmp.rglob("*"):
|
||||
if p.is_file() and p.suffix.lower() in DANGER_EXTS:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _safe_http_url(url: str) -> bool:
|
||||
"""校验链接仅允许 http/https 协议(防 javascript: 等危险协议)。"""
|
||||
return bool(re.match(r"^https?://", (url or "").strip()))
|
||||
|
||||
|
||||
def _process_zip(file: UploadFile, github_url: str) -> dict:
|
||||
"""保存 zip 并检测类型。返回检测结果;失败时清理已保存文件并抛异常。"""
|
||||
original = file.filename or ""
|
||||
ext = original.rsplit(".", 1)[-1].lower() if "." in original else ""
|
||||
if ext != "zip":
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 zip 压缩包")
|
||||
|
||||
content = file.file.read(MAX_PROJECT_SIZE + 1)
|
||||
if len(content) > MAX_PROJECT_SIZE:
|
||||
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="项目文件超出 50MB 限制")
|
||||
if content[:4] not in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容不是有效的 zip")
|
||||
|
||||
# 保存 zip 到 uploads/project/(随机文件名)
|
||||
saved_name = f"{uuid.uuid4().hex}.zip"
|
||||
proj_dir = UPLOADS_ROOT / "project"
|
||||
proj_dir.mkdir(parents=True, exist_ok=True)
|
||||
zip_path = proj_dir / saved_name
|
||||
zip_path.write_bytes(content)
|
||||
download_url = f"/uploads/project/{saved_name}"
|
||||
|
||||
# 解压到临时目录并检测
|
||||
tmpdir = Path(tempfile.mkdtemp(prefix="proj_"))
|
||||
try:
|
||||
_safe_extract(zip_path, tmpdir)
|
||||
web_root = _find_web_root(tmpdir)
|
||||
has_danger = _scan_danger(tmpdir)
|
||||
except HTTPException:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
if web_root is not None and not has_danger:
|
||||
result = {
|
||||
"project_type": PROJECT_TYPE_STATIC,
|
||||
"demo_url": None,
|
||||
"download_url": download_url,
|
||||
"zip_path": zip_path,
|
||||
"web_root": web_root,
|
||||
"tmpdir": tmpdir,
|
||||
}
|
||||
elif github_url.strip():
|
||||
result = {
|
||||
"project_type": PROJECT_TYPE_LINK,
|
||||
"demo_url": github_url.strip(),
|
||||
"download_url": download_url,
|
||||
"zip_path": zip_path,
|
||||
"web_root": None,
|
||||
"tmpdir": tmpdir,
|
||||
}
|
||||
else:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该压缩包无法在线演示(缺少 index.html 或包含后端代码),请填写 GitHub 链接",
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _deploy_static(project_id: int, web_root: Path) -> None:
|
||||
"""把静态网站根目录复制到 uploads/demos/{id}/。"""
|
||||
target = DEMOS_DIR / str(project_id)
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(web_root, target)
|
||||
# copytree 会保留源临时目录的 700 权限,导致 Nginx(www-data)无法读取;
|
||||
# 统一改为目录 755 / 文件 644,保证静态演示可被公开访问(顶层目录也要改)
|
||||
target.chmod(0o755)
|
||||
for item in target.rglob("*"):
|
||||
if item.is_dir():
|
||||
item.chmod(0o755)
|
||||
else:
|
||||
item.chmod(0o644)
|
||||
|
||||
|
||||
def _remove_demo(project_id: int) -> None:
|
||||
"""删除项目对应的在线演示目录。"""
|
||||
target = DEMOS_DIR / str(project_id)
|
||||
if target.exists():
|
||||
shutil.rmtree(target, ignore_errors=True)
|
||||
|
||||
|
||||
def _remove_zip(download_url: Optional[str]) -> None:
|
||||
"""删除项目 zip 文件(仅限站内 uploads/project/ 路径)。"""
|
||||
if not download_url:
|
||||
return
|
||||
parts = download_url.split("/")
|
||||
if len(parts) == 4 and parts[1] == "uploads" and parts[2] == "project":
|
||||
try:
|
||||
(UPLOADS_ROOT / "project" / parts[3]).unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/list", response_model=UnifiedResponse)
|
||||
def list_projects(
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""项目列表:公开项目所有人可见;好友项目仅好友/博主可见,游客不展示(项目无可脱敏字段)。"""
|
||||
privileged = can_read_friend_article(current_user)
|
||||
items = db.query(Project).order_by(Project.id.desc()).all()
|
||||
visible = [p for p in items if p.visibility != VISIBILITY_FRIEND or privileged]
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data=[ProjectOut.model_validate(p).model_dump() for p in visible],
|
||||
message="获取成功",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{project_id}", response_model=UnifiedResponse)
|
||||
def get_project(
|
||||
project_id: int,
|
||||
current_user: Optional[User] = Depends(get_optional_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""项目详情:好友项目对非好友隐藏存在性(返回 404)。"""
|
||||
project = db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
if project.visibility == VISIBILITY_FRIEND and not can_read_friend_article(current_user):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data=ProjectOut.model_validate(project).model_dump(),
|
||||
message="获取成功",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/add", response_model=UnifiedResponse)
|
||||
def add_project(
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
tech: str = Form(""),
|
||||
github_url: str = Form(""),
|
||||
visibility: str = Form("public"),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""添加项目:上传 zip 自动检测;纯静态 -> 在线演示,否则 -> GitHub 链接。支持 public / friend 可见性。"""
|
||||
_require_blogger(current_user)
|
||||
_check_visibility(visibility)
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写项目名称")
|
||||
if github_url.strip() and not _safe_http_url(github_url):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GitHub 链接格式不正确")
|
||||
|
||||
result = None
|
||||
if file is not None and file.filename:
|
||||
result = _process_zip(file, github_url)
|
||||
elif github_url.strip():
|
||||
result = {
|
||||
"project_type": PROJECT_TYPE_LINK,
|
||||
"demo_url": github_url.strip(),
|
||||
"download_url": None,
|
||||
"zip_path": None,
|
||||
"web_root": None,
|
||||
"tmpdir": None,
|
||||
}
|
||||
else:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请上传 zip 或填写 GitHub 链接")
|
||||
|
||||
project = Project(
|
||||
name=name,
|
||||
description=description.strip() or None,
|
||||
tech=tech.strip() or None,
|
||||
project_type=result["project_type"],
|
||||
visibility=visibility,
|
||||
demo_url=result["demo_url"],
|
||||
download_url=result["download_url"],
|
||||
github_url=github_url.strip() or None,
|
||||
)
|
||||
db.add(project)
|
||||
db.commit()
|
||||
db.refresh(project)
|
||||
|
||||
try:
|
||||
if result["project_type"] == PROJECT_TYPE_STATIC and result["web_root"] is not None:
|
||||
_deploy_static(project.id, result["web_root"])
|
||||
project.demo_url = f"/demo/{project.id}/"
|
||||
db.commit()
|
||||
except Exception:
|
||||
# 部署失败:回滚记录并清理文件,保证数据一致
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
_remove_zip(result["download_url"])
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="在线演示部署失败")
|
||||
|
||||
if result["tmpdir"]:
|
||||
shutil.rmtree(result["tmpdir"], ignore_errors=True)
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data=ProjectOut.model_validate(project).model_dump(),
|
||||
message="项目添加成功",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/{project_id}", response_model=UnifiedResponse)
|
||||
def update_project(
|
||||
project_id: int,
|
||||
name: str = Form(...),
|
||||
description: str = Form(""),
|
||||
tech: str = Form(""),
|
||||
github_url: str = Form(""),
|
||||
visibility: str = Form(""),
|
||||
file: Optional[UploadFile] = File(None),
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""更新项目:可修改信息或重新上传 zip(会重新检测类型并刷新在线演示)。visibility 留空表示不变。"""
|
||||
_require_blogger(current_user)
|
||||
if visibility:
|
||||
_check_visibility(visibility)
|
||||
project = db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
name = name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请填写项目名称")
|
||||
if github_url.strip() and not _safe_http_url(github_url):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="GitHub 链接格式不正确")
|
||||
|
||||
result = None
|
||||
if file is not None and file.filename:
|
||||
result = _process_zip(file, github_url)
|
||||
old_type = project.project_type
|
||||
old_zip = project.download_url
|
||||
project.name = name
|
||||
project.description = description.strip() or None
|
||||
project.tech = tech.strip() or None
|
||||
if visibility:
|
||||
project.visibility = visibility
|
||||
project.project_type = result["project_type"]
|
||||
project.demo_url = result["demo_url"]
|
||||
project.download_url = result["download_url"]
|
||||
project.github_url = github_url.strip() or None
|
||||
db.commit()
|
||||
try:
|
||||
if result["project_type"] == PROJECT_TYPE_STATIC and result["web_root"] is not None:
|
||||
_deploy_static(project.id, result["web_root"])
|
||||
project.demo_url = f"/demo/{project.id}/"
|
||||
db.commit()
|
||||
# 清理旧 zip 与旧演示目录
|
||||
_remove_zip(old_zip)
|
||||
# 原为静态、更新后不再是静态:清理旧的在线演示目录
|
||||
if old_type == PROJECT_TYPE_STATIC and result["project_type"] != PROJECT_TYPE_STATIC:
|
||||
_remove_demo(project.id)
|
||||
except Exception:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="更新失败")
|
||||
else:
|
||||
# 未重新上传文件:只更新文本信息
|
||||
project.name = name
|
||||
project.description = description.strip() or None
|
||||
project.tech = tech.strip() or None
|
||||
if visibility:
|
||||
project.visibility = visibility
|
||||
if project.project_type == PROJECT_TYPE_LINK:
|
||||
new_github = github_url.strip()
|
||||
if not new_github and not project.github_url:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="非静态项目必须填写 GitHub 链接")
|
||||
if new_github:
|
||||
project.github_url = new_github
|
||||
project.demo_url = new_github
|
||||
db.commit()
|
||||
|
||||
if result and result["tmpdir"]:
|
||||
shutil.rmtree(result["tmpdir"], ignore_errors=True)
|
||||
return UnifiedResponse(
|
||||
success=True,
|
||||
data=ProjectOut.model_validate(project).model_dump(),
|
||||
message="项目已更新",
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/{project_id}", response_model=UnifiedResponse)
|
||||
def delete_project(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""删除项目:同时清理 zip 与在线演示目录。"""
|
||||
_require_blogger(current_user)
|
||||
project = db.get(Project, project_id)
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
zip_url = project.download_url
|
||||
_remove_demo(project.id)
|
||||
_remove_zip(zip_url)
|
||||
db.delete(project)
|
||||
db.commit()
|
||||
return UnifiedResponse(success=True, data=None, message="项目已删除")
|
||||
@@ -0,0 +1,193 @@
|
||||
"""
|
||||
文件上传路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/upload/avatar 头像上传(登录用户;jpg/png/webp,最大 2MB;成功后自动保存到头像字段)
|
||||
- POST /api/upload/article 文章图片上传(仅博主;jpg/png/webp,最大 6MB)
|
||||
- POST /api/upload/project 项目文件上传(仅博主;zip,最大 50MB)
|
||||
- POST /api/upload/doc 文章文档上传(仅博主;doc/docx,默认最大 20MB;仅用于生活/学习分区)
|
||||
|
||||
安全规则:
|
||||
- 随机文件名(uuid),不使用用户原始文件名,杜绝路径穿越
|
||||
- 校验扩展名 + 文件魔数(内容真实性),防止伪装类型
|
||||
- 文件仅作为静态资源由 Nginx 通过 /uploads/ 访问,禁止执行
|
||||
"""
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..database import PROJECT_ROOT, get_db
|
||||
from ..models import (
|
||||
ARTICLE_CATEGORY_LIFE,
|
||||
ARTICLE_CATEGORY_STUDY,
|
||||
ROLE_BLOGGER,
|
||||
ROLE_FRIEND,
|
||||
User,
|
||||
utcnow,
|
||||
)
|
||||
from ..schemas import UnifiedResponse
|
||||
|
||||
router = APIRouter(prefix="/api/upload", tags=["upload"])
|
||||
|
||||
# 上传根目录(可通过 .env 的 UPLOADS_DIR 覆盖;部署时 Nginx 将 /uploads/ 静态映射到该目录)
|
||||
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
|
||||
|
||||
# 允许的扩展名(值仅用于展示)
|
||||
ALLOWED_IMAGES = {"jpg", "jpeg", "png", "webp"}
|
||||
ALLOWED_VIDEO = {"mp4", "webm"}
|
||||
ALLOWED_PROJECT = {"zip"}
|
||||
ALLOWED_DOC = {"doc", "docx"}
|
||||
|
||||
# 大小上限(字节):头像上限可通过 .env 的 AVATAR_MAX_SIZE_MB 调整(默认 4MB)
|
||||
MAX_AVATAR_SIZE = int(os.getenv("AVATAR_MAX_SIZE_MB", "4")) * 1024 * 1024
|
||||
# 文章图片(封面/正文插图)大小上限:可通过 .env 的 ARTICLE_IMAGE_MAX_SIZE_MB 调整(默认 6MB)
|
||||
MAX_IMAGE_SIZE = int(os.getenv("ARTICLE_IMAGE_MAX_SIZE_MB", "6")) * 1024 * 1024
|
||||
MAX_VIDEO_SIZE = 100 * 1024 * 1024 # 文章视频 100MB
|
||||
MAX_PROJECT_SIZE = 50 * 1024 * 1024 # 项目文件 50MB
|
||||
# 文章文档(doc/docx)大小上限:可通过 .env 的 DOC_MAX_SIZE_MB 调整(默认 20MB)
|
||||
MAX_DOC_SIZE = int(os.getenv("DOC_MAX_SIZE_MB", "20")) * 1024 * 1024
|
||||
|
||||
|
||||
def check_magic_bytes(content: bytes, ext: str) -> bool:
|
||||
"""校验文件魔数,防止伪造扩展名。"""
|
||||
if ext in ("jpg", "jpeg"):
|
||||
return content[:3] == b"\xff\xd8\xff"
|
||||
if ext == "png":
|
||||
return content[:8] == b"\x89PNG\r\n\x1a\n"
|
||||
if ext == "webp":
|
||||
return content[:4] == b"RIFF" and content[8:12] == b"WEBP"
|
||||
if ext == "mp4":
|
||||
return content[4:8] == b"ftyp"
|
||||
if ext == "webm":
|
||||
return content[:4] == b"\x1aE\xdf\xa3"
|
||||
if ext == "zip":
|
||||
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
if ext == "doc":
|
||||
# Word 97-2003:OLE 复合文档魔数
|
||||
return content[:8] == b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1"
|
||||
if ext == "docx":
|
||||
# Word 2007+:本质是 zip 压缩包
|
||||
return content[:4] in (b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")
|
||||
return False
|
||||
|
||||
|
||||
def save_upload(file: UploadFile, subdir: str, allowed: set, max_size: int) -> dict:
|
||||
"""校验并保存上传文件,返回 URL 等元信息。"""
|
||||
original_name = file.filename or ""
|
||||
ext = original_name.rsplit(".", 1)[-1].lower() if "." in original_name else ""
|
||||
if ext not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"不支持的文件类型(允许:{' / '.join(sorted(allowed))})",
|
||||
)
|
||||
|
||||
# 一次读入,超出上限直接拒绝(+1 用于判断是否超限)
|
||||
content = file.file.read(max_size + 1)
|
||||
if len(content) > max_size:
|
||||
raise HTTPException(status_code=status.HTTP_413_CONTENT_TOO_LARGE, detail="文件大小超出限制")
|
||||
|
||||
if not check_magic_bytes(content, ext):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="文件内容与扩展名不符")
|
||||
|
||||
# 随机文件名,避免覆盖与路径穿越
|
||||
saved_name = f"{uuid.uuid4().hex}.{ext}"
|
||||
target_dir = UPLOADS_ROOT / subdir
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
(target_dir / saved_name).write_bytes(content)
|
||||
|
||||
return {
|
||||
"url": f"/uploads/{subdir}/{saved_name}",
|
||||
"filename": original_name,
|
||||
"size": len(content),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/avatar", response_model=UnifiedResponse)
|
||||
def upload_avatar(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""头像上传:仅好友或博主可上传(访客除注册外禁止一切上传);成功后自动更新头像字段。
|
||||
更换频率限制:同一账号两次更换之间至少间隔 AVATAR_CHANGE_INTERVAL_HOURS 小时(默认 24)。
|
||||
"""
|
||||
if current_user.role not in (ROLE_FRIEND, ROLE_BLOGGER):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="访客不能上传头像")
|
||||
|
||||
# 更换冷却:距离上次更换不足 N 小时则拒绝,并提示剩余等待时间
|
||||
interval_hours = int(os.getenv("AVATAR_CHANGE_INTERVAL_HOURS", "24"))
|
||||
if current_user.avatar_updated_time is not None:
|
||||
elapsed = utcnow() - current_user.avatar_updated_time
|
||||
if elapsed < timedelta(hours=interval_hours):
|
||||
remaining = timedelta(hours=interval_hours) - elapsed
|
||||
hours = int(remaining.total_seconds() // 3600)
|
||||
minutes = int((remaining.total_seconds() % 3600) // 60)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail=f"头像更换过于频繁,请约 {hours} 小时 {minutes} 分钟后再试",
|
||||
)
|
||||
|
||||
data = save_upload(file, "avatar", ALLOWED_IMAGES, MAX_AVATAR_SIZE)
|
||||
current_user.avatar = data["url"]
|
||||
current_user.avatar_updated_time = utcnow()
|
||||
db.commit()
|
||||
return UnifiedResponse(success=True, data=data, message="头像上传成功")
|
||||
|
||||
|
||||
@router.post("/article", response_model=UnifiedResponse)
|
||||
def upload_article_image(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章图片上传:仅博主可上传(用于封面与正文插图)。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章图片")
|
||||
data = save_upload(file, "article", ALLOWED_IMAGES, MAX_IMAGE_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="图片上传成功")
|
||||
|
||||
|
||||
@router.post("/video", response_model=UnifiedResponse)
|
||||
def upload_article_video(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章视频上传:仅博主可上传(mp4 / webm,最大 100MB),供正文插入视频。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文章视频")
|
||||
data = save_upload(file, "article", ALLOWED_VIDEO, MAX_VIDEO_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="视频上传成功")
|
||||
|
||||
|
||||
@router.post("/project", response_model=UnifiedResponse)
|
||||
def upload_project_file(
|
||||
file: UploadFile,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""项目文件上传:仅博主可上传,支持 zip 压缩包(供下载项目使用)。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传项目文件")
|
||||
data = save_upload(file, "project", ALLOWED_PROJECT, MAX_PROJECT_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="项目文件上传成功")
|
||||
|
||||
@router.post("/doc", response_model=UnifiedResponse)
|
||||
def upload_article_doc(
|
||||
file: UploadFile,
|
||||
category: str = Form(...),
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""文章文档上传(doc / docx):仅博主可上传,且只能用于“我的生活 / 我的学习”分区的文章。"""
|
||||
if current_user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以上传文档")
|
||||
if category not in (ARTICLE_CATEGORY_LIFE, ARTICLE_CATEGORY_STUDY):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="文档只能用于生活或学习分区的文章",
|
||||
)
|
||||
data = save_upload(file, "article", ALLOWED_DOC, MAX_DOC_SIZE)
|
||||
return UnifiedResponse(success=True, data=data, message="文档上传成功")
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
用户认证与资料路由。
|
||||
|
||||
实现接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/register 注册:邮箱 + 用户名 + 密码(≥6 位),密码 bcrypt 加密存储
|
||||
- POST /api/login 登录:校验密码,成功返回 JWT 令牌与用户基础信息(带失败限流)
|
||||
- GET /api/user/level 查询当前登录用户的权限级别(角色)
|
||||
- GET /api/user/me 查询当前登录用户的完整资料(头像、简介)
|
||||
- GET /api/user/blogger 查询博主公开资料(我的简介页面,无需登录)
|
||||
- PUT /api/user/profile 更新当前用户资料(头像 / 简介)
|
||||
"""
|
||||
|
||||
import os
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import create_access_token, get_current_user, hash_password, verify_password
|
||||
from ..database import get_db
|
||||
from ..models import PURPOSE_REGISTER, ROLE_BLOGGER, ROLE_VISITOR, User
|
||||
from ..schemas import (
|
||||
LoginRequest,
|
||||
ProfileOut,
|
||||
ProfileUpdate,
|
||||
TokenOut,
|
||||
UnifiedResponse,
|
||||
UserCreate,
|
||||
UserLevelOut,
|
||||
VerifyCodeRequest,
|
||||
)
|
||||
from ..security import (
|
||||
get_client_ip,
|
||||
is_valid_email,
|
||||
login_failure_limiter,
|
||||
login_ip_limiter,
|
||||
register_ip_limiter,
|
||||
)
|
||||
from .email import verify_code
|
||||
|
||||
router = APIRouter(prefix="/api", tags=["auth"])
|
||||
|
||||
# 密码长度限制(可通过 .env 的 PASSWORD_MIN_LENGTH 调整,默认 6;前端同步校验)
|
||||
PASSWORD_MIN_LENGTH = int(os.getenv("PASSWORD_MIN_LENGTH") or "6")
|
||||
PASSWORD_MAX_LENGTH = 128
|
||||
|
||||
|
||||
@router.post("/register", response_model=UnifiedResponse)
|
||||
def register(payload: UserCreate, request: Request, db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""注册新用户:邮箱、用户名唯一,密码以 bcrypt 哈希存储,角色固定为 visitor。
|
||||
|
||||
安全要求:必须携带邮箱验证码(防垃圾注册与邮箱盗用),并按 IP 限流。
|
||||
"""
|
||||
email = payload.email.strip().lower()
|
||||
username = payload.username.strip()
|
||||
password = payload.password
|
||||
|
||||
# 注册限流(IP 维度):同一 IP 每小时最多 3 次,防止批量注册
|
||||
client_ip = get_client_ip(request)
|
||||
if register_ip_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="注册过于频繁,请稍后再试")
|
||||
|
||||
# 格式校验(与前端规则保持一致)
|
||||
if not is_valid_email(email):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="邮箱格式不正确")
|
||||
if len(username) < 2:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名至少 2 个字符")
|
||||
if len(username) > 50:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户名不能超过 50 个字符")
|
||||
if len(password) < PASSWORD_MIN_LENGTH:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码至少 6 位")
|
||||
if len(password) > PASSWORD_MAX_LENGTH:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="密码不能超过 128 位")
|
||||
|
||||
# 邮箱验证码校验:未提供或错误时拒绝注册(校验通过后验证码一次性作废)
|
||||
if not payload.code.strip():
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请先获取并填写邮箱验证码")
|
||||
verify_code(VerifyCodeRequest(email=email, code=payload.code, purpose=PURPOSE_REGISTER), db)
|
||||
|
||||
# 唯一性校验:邮箱或用户名任一冲突即返回统一提示,不区分具体是哪一项,
|
||||
# 防止攻击者通过注册接口枚举已注册的邮箱 / 用户名
|
||||
email_taken = db.query(User).filter(User.email == email).first() is not None
|
||||
username_taken = db.query(User).filter(User.username == username).first() is not None
|
||||
if email_taken or username_taken:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="该邮箱或用户名已被使用,请直接登录或更换后重试",
|
||||
)
|
||||
|
||||
# 注册用户固定为 visitor 角色,防止通过注册接口越权提升权限
|
||||
user = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_VISITOR,
|
||||
)
|
||||
db.add(user)
|
||||
db.commit()
|
||||
db.refresh(user)
|
||||
register_ip_limiter.hit(client_ip)
|
||||
|
||||
data = {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="注册成功")
|
||||
|
||||
|
||||
@router.post("/login", response_model=UnifiedResponse)
|
||||
def login(payload: LoginRequest, request: Request, db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""登录:校验邮箱与密码,成功后返回 JWT 令牌;失败过多时按邮箱与 IP 双重限流。"""
|
||||
email = payload.email.strip().lower()
|
||||
client_ip = get_client_ip(request)
|
||||
|
||||
# IP 维度限流:同一 IP 在窗口内尝试过多直接拒绝(防分布式爆破)
|
||||
if login_ip_limiter.is_blocked(client_ip):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="尝试次数过多,请稍后再试",
|
||||
)
|
||||
# 邮箱维度限流:同一邮箱 15 分钟内失败 5 次后直接拒绝,防止暴力破解
|
||||
if login_failure_limiter.is_blocked(email):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
||||
detail="尝试次数过多,请 15 分钟后再试",
|
||||
)
|
||||
|
||||
user = db.query(User).filter(User.email == email).first()
|
||||
|
||||
# 统一提示,避免泄露用户是否存在;失败同时记录邮箱与 IP 维度计数
|
||||
if user is None or not verify_password(payload.password, user.password_hash):
|
||||
login_failure_limiter.hit(email)
|
||||
login_ip_limiter.hit(client_ip)
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="邮箱或密码错误")
|
||||
|
||||
# 登录成功:重置两类失败计数并返回令牌与用户信息
|
||||
login_failure_limiter.reset(email)
|
||||
login_ip_limiter.reset(client_ip)
|
||||
token = create_access_token(user)
|
||||
data = TokenOut(
|
||||
token=token,
|
||||
user_id=user.id,
|
||||
username=user.username,
|
||||
role=user.role,
|
||||
).model_dump()
|
||||
return UnifiedResponse(success=True, data=data, message="登录成功")
|
||||
|
||||
|
||||
@router.get("/user/level", response_model=UnifiedResponse)
|
||||
def get_user_level(current_user: User = Depends(get_current_user)) -> UnifiedResponse:
|
||||
"""查询当前登录用户的权限级别(角色)。"""
|
||||
level = UserLevelOut(user_id=current_user.id, role=current_user.role)
|
||||
return UnifiedResponse(success=True, data=level.model_dump(), message="获取成功")
|
||||
|
||||
|
||||
@router.get("/user/me", response_model=UnifiedResponse)
|
||||
def get_my_profile(current_user: User = Depends(get_current_user)) -> UnifiedResponse:
|
||||
"""查询当前登录用户的完整资料(含头像、简介),供个人设置使用。"""
|
||||
profile = ProfileOut.model_validate(current_user)
|
||||
return UnifiedResponse(success=True, data=profile.model_dump(), message="获取成功")
|
||||
|
||||
|
||||
@router.get("/user/blogger", response_model=UnifiedResponse)
|
||||
def get_blogger_profile(db: Session = Depends(get_db)) -> UnifiedResponse:
|
||||
"""查询博主公开资料(我的简介页面,无需登录)。"""
|
||||
blogger = db.query(User).filter(User.role == ROLE_BLOGGER).order_by(User.id.asc()).first()
|
||||
if blogger is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="博主资料不存在")
|
||||
data = {
|
||||
"username": blogger.username,
|
||||
"avatar": blogger.avatar,
|
||||
"bio": blogger.bio,
|
||||
"role": blogger.role,
|
||||
}
|
||||
return UnifiedResponse(success=True, data=data, message="获取成功")
|
||||
|
||||
|
||||
@router.put("/user/profile", response_model=UnifiedResponse)
|
||||
def update_profile(
|
||||
payload: ProfileUpdate,
|
||||
current_user: User = Depends(get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
) -> UnifiedResponse:
|
||||
"""更新当前用户资料:头像路径与个人简介(博主简介展示在“我的简介”页面)。"""
|
||||
if payload.avatar is not None:
|
||||
avatar = payload.avatar.strip() or None
|
||||
# 头像仅允许站内上传路径(禁 http/https 外链,防止追踪与钓鱼图片)
|
||||
if avatar and not avatar.startswith("/uploads/"):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="头像地址不合法,请使用站内上传的头像")
|
||||
current_user.avatar = avatar
|
||||
if payload.bio is not None:
|
||||
bio = payload.bio.strip()
|
||||
current_user.bio = bio or None
|
||||
db.commit()
|
||||
db.refresh(current_user)
|
||||
profile = ProfileOut.model_validate(current_user)
|
||||
return UnifiedResponse(success=True, data=profile.model_dump(), message="资料已更新")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""JS → WASM 预编译路由。
|
||||
|
||||
接口(统一响应格式 {success, data, message}):
|
||||
- POST /api/project/{id}/precompile 把项目 JS 预编译为 WASM(仅博主)
|
||||
- GET /api/project/{id}/wasm-report 查看最近一次编译报告(公开,仅元信息)
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
|
||||
from ..auth import get_current_user
|
||||
from ..models import ROLE_BLOGGER, User
|
||||
from ..schemas import UnifiedResponse
|
||||
from ..services import wasm_builder
|
||||
|
||||
router = APIRouter(prefix="/api/project", tags=["project-wasm"])
|
||||
|
||||
|
||||
def _require_blogger(user: User) -> None:
|
||||
"""校验当前用户是否为博主(与 routers/project.py 保持一致)。"""
|
||||
if user.role != ROLE_BLOGGER:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN, detail="只有博主可以管理项目",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{project_id}/precompile", response_model=UnifiedResponse)
|
||||
def precompile_project(
|
||||
project_id: int,
|
||||
current_user: User = Depends(get_current_user),
|
||||
) -> UnifiedResponse:
|
||||
"""把项目演示目录里的 .js 文件预编译为 WASM(仅博主,不执行上传代码)。"""
|
||||
_require_blogger(current_user)
|
||||
report = wasm_builder.compile_project(project_id)
|
||||
return UnifiedResponse(success=True, data=report, message="JS 预编译完成")
|
||||
|
||||
|
||||
@router.get("/{project_id}/wasm-report", response_model=UnifiedResponse)
|
||||
def get_wasm_report(project_id: int) -> UnifiedResponse:
|
||||
"""查看项目最近一次 WASM 编译报告(公开:仅文件名与大小等元信息)。"""
|
||||
report_path = wasm_builder.DEMOS_DIR / str(project_id) / "wasm" / "report.json"
|
||||
if not report_path.is_file():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="该项目尚未执行 WASM 预编译",
|
||||
)
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
return UnifiedResponse(success=True, data=report, message="")
|
||||
@@ -0,0 +1,222 @@
|
||||
"""
|
||||
Pydantic 数据校验模型(Schemas)。
|
||||
|
||||
职责:
|
||||
- 定义前后端 JSON 通信所需的数据结构
|
||||
- 统一定义全局响应格式 {success, data, message}
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class UnifiedResponse(BaseModel):
|
||||
"""全局统一响应格式:所有接口返回该结构。"""
|
||||
|
||||
success: bool = True
|
||||
data: Optional[Any] = None
|
||||
message: str = ""
|
||||
|
||||
|
||||
class UserCreate(BaseModel):
|
||||
"""创建用户(注册)请求体:需携带邮箱验证码;角色固定为 visitor(后端强制)。"""
|
||||
|
||||
email: str = Field(max_length=255)
|
||||
username: str = Field(min_length=2, max_length=50)
|
||||
password: str = Field(max_length=128)
|
||||
code: str = Field(default="", max_length=10, description="邮箱验证码(注册前通过 /api/email/send-code 获取)")
|
||||
|
||||
|
||||
class UserOut(BaseModel):
|
||||
"""用户信息输出(不含密码哈希)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
role: str
|
||||
created_time: datetime
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
"""登录请求体。"""
|
||||
|
||||
email: str
|
||||
password: str
|
||||
|
||||
|
||||
class TokenOut(BaseModel):
|
||||
"""登录成功返回的 JWT 令牌与用户基础信息。"""
|
||||
|
||||
token: str
|
||||
user_id: int
|
||||
username: str
|
||||
role: str
|
||||
|
||||
|
||||
class UserLevelOut(BaseModel):
|
||||
"""用户权限级别(角色)输出。"""
|
||||
|
||||
user_id: int
|
||||
role: str
|
||||
|
||||
|
||||
class ProfileOut(BaseModel):
|
||||
"""个人资料输出(头像、简介等)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
email: str
|
||||
username: str
|
||||
role: str
|
||||
avatar: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
created_time: datetime
|
||||
|
||||
|
||||
class BloggerProfileOut(BaseModel):
|
||||
"""博主公开资料输出(我的简介页面使用)。"""
|
||||
|
||||
username: str
|
||||
avatar: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
role: str
|
||||
|
||||
|
||||
class ProfileUpdate(BaseModel):
|
||||
"""更新个人资料请求体(头像 / 简介,均可选)。"""
|
||||
|
||||
avatar: Optional[str] = Field(default=None, max_length=500)
|
||||
bio: Optional[str] = Field(default=None, max_length=2000)
|
||||
|
||||
|
||||
class SendCodeRequest(BaseModel):
|
||||
"""发送邮箱验证码请求体。"""
|
||||
|
||||
email: str
|
||||
purpose: str
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
"""校验邮箱验证码请求体。"""
|
||||
|
||||
email: str
|
||||
code: str
|
||||
purpose: str
|
||||
|
||||
|
||||
class ForgotPasswordRequest(BaseModel):
|
||||
"""忘记密码请求体。"""
|
||||
|
||||
email: str
|
||||
|
||||
|
||||
class ResetPasswordRequest(BaseModel):
|
||||
"""重置密码请求体。"""
|
||||
|
||||
email: str
|
||||
code: str
|
||||
new_password: str = Field(max_length=128)
|
||||
|
||||
|
||||
class ArticleCreate(BaseModel):
|
||||
"""创建文章请求体。"""
|
||||
|
||||
title: str = Field(min_length=1, max_length=200)
|
||||
content: str = Field(min_length=1, max_length=200000)
|
||||
cover: Optional[str] = Field(default=None, max_length=500)
|
||||
visibility: str = "public"
|
||||
category: str = Field(default="life", description="分区:life 生活 / study 学习(取值由路由校验,统一 400 提示)")
|
||||
|
||||
|
||||
class ArticleUpdate(BaseModel):
|
||||
"""更新文章请求体(字段均可选,仅更新传入的字段)。"""
|
||||
|
||||
title: Optional[str] = Field(default=None, min_length=1, max_length=200)
|
||||
content: Optional[str] = Field(default=None, min_length=1, max_length=200000)
|
||||
cover: Optional[str] = Field(default=None, max_length=500)
|
||||
visibility: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
|
||||
|
||||
class ArticleOut(BaseModel):
|
||||
"""文章信息输出。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
cover: Optional[str] = None
|
||||
visibility: str
|
||||
author_id: int
|
||||
created_time: datetime
|
||||
|
||||
|
||||
class CommentCreate(BaseModel):
|
||||
"""发表评论请求体(parent_id 用于回复,顶层评论不填)。"""
|
||||
|
||||
article_id: int
|
||||
content: str = Field(min_length=1, max_length=5000)
|
||||
parent_id: Optional[int] = None
|
||||
|
||||
|
||||
class CommentOut(BaseModel):
|
||||
"""评论信息输出。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
article_id: int
|
||||
user_id: int
|
||||
parent_id: Optional[int] = None
|
||||
content: str
|
||||
created_time: datetime
|
||||
|
||||
|
||||
class ProjectOut(BaseModel):
|
||||
"""项目信息输出(在线演示 / GitHub 外链)。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
tech: Optional[str] = None
|
||||
project_type: str
|
||||
visibility: str
|
||||
demo_url: Optional[str] = None
|
||||
download_url: Optional[str] = None
|
||||
github_url: Optional[str] = None
|
||||
created_time: datetime
|
||||
updated_time: datetime
|
||||
|
||||
|
||||
class LikeCreate(BaseModel):
|
||||
"""点赞请求体。"""
|
||||
|
||||
article_id: int
|
||||
|
||||
|
||||
class LikeOut(BaseModel):
|
||||
"""点赞信息输出。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
article_id: int
|
||||
user_id: int
|
||||
|
||||
|
||||
class FriendOut(BaseModel):
|
||||
"""好友申请信息输出。"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
user_id: int
|
||||
status: str
|
||||
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
安全工具模块。
|
||||
|
||||
职责:
|
||||
- 邮箱格式校验(统一规则,各路由复用)
|
||||
- 客户端 IP 提取(兼容 Nginx X-Forwarded-For)
|
||||
- 内存限流器:登录失败锁定、验证码发送/校验频率控制
|
||||
|
||||
说明:
|
||||
- 本项目为单进程 uvicorn 部署,内存限流器即可满足需求;
|
||||
若未来改为多进程部署,可将实现替换为 Redis 等共享存储。
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
from threading import Lock
|
||||
from typing import Deque, Dict
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
|
||||
def _env_int(name: str, default: int) -> int:
|
||||
"""读取整数型环境变量,缺失或非法时返回默认值。"""
|
||||
try:
|
||||
return int(os.getenv(name, "").strip() or default)
|
||||
except ValueError:
|
||||
return default
|
||||
|
||||
|
||||
# ---------- 邮箱校验 ----------
|
||||
|
||||
EMAIL_PATTERN = r"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$"
|
||||
|
||||
|
||||
def is_valid_email(email: str) -> bool:
|
||||
"""校验邮箱基本格式,返回是否合法。"""
|
||||
return bool(re.match(EMAIL_PATTERN, email or ""))
|
||||
|
||||
|
||||
# ---------- 客户端 IP ----------
|
||||
|
||||
def get_client_ip(request: Request) -> str:
|
||||
"""获取客户端 IP:优先信任 Nginx 写入的 X-Forwarded-For,否则取直连地址。"""
|
||||
forwarded = request.headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
# ---------- 内存限流器 ----------
|
||||
|
||||
class RateLimiter:
|
||||
"""滑动窗口限流器:记录 key 在窗口内的访问次数,超过阈值判定为阻塞。"""
|
||||
|
||||
def __init__(self, max_events: int, window_seconds: int):
|
||||
self.max_events = max_events
|
||||
self.window_seconds = window_seconds
|
||||
self._records: Dict[str, Deque[float]] = defaultdict(deque)
|
||||
self._lock = Lock()
|
||||
|
||||
def _prune(self, key: str, now: float) -> None:
|
||||
"""清理窗口外的历史记录,避免内存无限增长。"""
|
||||
queue = self._records[key]
|
||||
while queue and now - queue[0] > self.window_seconds:
|
||||
queue.popleft()
|
||||
|
||||
def hit(self, key: str) -> int:
|
||||
"""记录一次事件,返回窗口内的总次数。"""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._prune(key, now)
|
||||
self._records[key].append(now)
|
||||
return len(self._records[key])
|
||||
|
||||
def is_blocked(self, key: str) -> bool:
|
||||
"""判断当前是否已达阈值(阻塞)。"""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._prune(key, now)
|
||||
return len(self._records[key]) >= self.max_events
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
"""清空指定 key 的记录(如登录成功后重置失败计数)。"""
|
||||
with self._lock:
|
||||
self._records.pop(key, None)
|
||||
|
||||
|
||||
# ---------- 限流策略(阈值与窗口,均可通过 .env 调整,默认值见下) ----------
|
||||
|
||||
# 登录失败:同一邮箱在窗口内失败达到上限后临时锁定,防止暴力破解博主账号
|
||||
LOGIN_MAX_FAILURES = _env_int("LOGIN_MAX_FAILURES", 5)
|
||||
LOGIN_WINDOW_SECONDS = _env_int("LOGIN_WINDOW_SECONDS", 15 * 60)
|
||||
login_failure_limiter = RateLimiter(LOGIN_MAX_FAILURES, LOGIN_WINDOW_SECONDS)
|
||||
|
||||
# 验证码发送:同一邮箱每小时最多 N 封、同一 IP 每小时最多 N 封,防止被当作垃圾邮件中继
|
||||
SEND_CODE_MAX_PER_EMAIL = _env_int("SEND_CODE_MAX_PER_EMAIL", 5)
|
||||
SEND_CODE_MAX_PER_IP = _env_int("SEND_CODE_MAX_PER_IP", 10)
|
||||
SEND_CODE_WINDOW_SECONDS = _env_int("SEND_CODE_WINDOW_SECONDS", 60 * 60)
|
||||
send_code_email_limiter = RateLimiter(SEND_CODE_MAX_PER_EMAIL, SEND_CODE_WINDOW_SECONDS)
|
||||
send_code_ip_limiter = RateLimiter(SEND_CODE_MAX_PER_IP, SEND_CODE_WINDOW_SECONDS)
|
||||
|
||||
# 验证码校验:同一邮箱在验证码有效期内错误尝试达到上限后作废验证码,防 6 位验证码被暴力枚举
|
||||
VERIFY_CODE_MAX_ATTEMPTS = _env_int("VERIFY_CODE_MAX_ATTEMPTS", 5)
|
||||
VERIFY_CODE_WINDOW_SECONDS = _env_int("VERIFY_CODE_WINDOW_SECONDS", 10 * 60)
|
||||
verify_code_limiter = RateLimiter(VERIFY_CODE_MAX_ATTEMPTS, VERIFY_CODE_WINDOW_SECONDS)
|
||||
# 注册限流:同一 IP 每小时最多 3 次注册,防止批量注册垃圾账号(可通过 .env 调整)
|
||||
REGISTER_MAX_PER_IP = _env_int("REGISTER_MAX_PER_IP", 3)
|
||||
REGISTER_WINDOW_SECONDS = _env_int("REGISTER_WINDOW_SECONDS", 60 * 60)
|
||||
register_ip_limiter = RateLimiter(REGISTER_MAX_PER_IP, REGISTER_WINDOW_SECONDS)
|
||||
|
||||
# 登录限流(IP 维度):同一 IP 在登录窗口内最多尝试 30 次,配合邮箱维度防分布式爆破
|
||||
LOGIN_MAX_PER_IP = _env_int("LOGIN_MAX_PER_IP", 30)
|
||||
login_ip_limiter = RateLimiter(LOGIN_MAX_PER_IP, LOGIN_WINDOW_SECONDS)
|
||||
|
||||
# 写操作限流(评论/点赞):同一 IP 每分钟最多 30 次,防止好友账号刷屏
|
||||
ACTION_MAX_PER_MINUTE = _env_int("ACTION_MAX_PER_MINUTE", 30)
|
||||
action_limiter = RateLimiter(ACTION_MAX_PER_MINUTE, 60)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
博主账号初始化脚本。
|
||||
|
||||
用法:
|
||||
1. 在 .env 中配置 BLOGGER_USERNAME / BLOGGER_EMAIL / BLOGGER_PASSWORD
|
||||
2. 运行:python -m backend.seed
|
||||
|
||||
脚本会创建全局唯一博主账号(bcrypt 哈希存储密码);已存在则跳过。
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from .auth import hash_password
|
||||
from .database import PROJECT_ROOT, SessionLocal, init_db
|
||||
from .models import ROLE_BLOGGER, User
|
||||
|
||||
|
||||
def seed_blogger() -> None:
|
||||
"""创建唯一博主账号(已存在则跳过)。"""
|
||||
username = os.getenv("BLOGGER_USERNAME", "").strip()
|
||||
email = os.getenv("BLOGGER_EMAIL", "").strip()
|
||||
password = os.getenv("BLOGGER_PASSWORD", "")
|
||||
|
||||
if not (username and email and password):
|
||||
raise SystemExit("请在 .env 中配置 BLOGGER_USERNAME / BLOGGER_EMAIL / BLOGGER_PASSWORD")
|
||||
if "@" not in email:
|
||||
raise SystemExit("BLOGGER_EMAIL 格式不正确")
|
||||
|
||||
init_db()
|
||||
db = SessionLocal()
|
||||
try:
|
||||
exists = db.query(User).filter((User.email == email) | (User.username == username)).first()
|
||||
if exists is not None:
|
||||
print(f"博主账号已存在(id={exists.id}, username={exists.username}),跳过创建")
|
||||
return
|
||||
blogger = User(
|
||||
email=email,
|
||||
username=username,
|
||||
password_hash=hash_password(password),
|
||||
role=ROLE_BLOGGER,
|
||||
)
|
||||
db.add(blogger)
|
||||
db.commit()
|
||||
print(f"博主账号创建成功(id={blogger.id}, username={blogger.username})")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
seed_blogger()
|
||||
@@ -0,0 +1,309 @@
|
||||
"""JS → WASM 预编译服务(基于 Javy / QuickJS)。
|
||||
|
||||
职责:
|
||||
- 调用 Javy 把项目演示目录里的 .js 文件预编译为 .wasm 模块
|
||||
- 把项目“下载压缩包”里的全部 JS 合并打包为单个 .wasm(与 zip 同目录,可下载)
|
||||
- 产物与 report.json 编译报告一起输出,只做“编译”,绝不执行任何上传代码
|
||||
|
||||
使用场景说明:
|
||||
- 浏览器里的 JS 无法真正“预编译”成 WASM 后代替原脚本运行:
|
||||
浏览器自带 V8 JIT 运行时编译,本身就很快;而 Javy 产物是
|
||||
“QuickJS 解释器 + 字节码”,更慢且无法操作 DOM,因此演示页
|
||||
仍然运行原始 JS 文件。
|
||||
- Javy 产物真正有用的场景是“服务端沙箱执行”:把不可信的 JS
|
||||
关进 WASM 沙箱里运行,隔离文件/网络/系统访问,而不是直接在
|
||||
服务器上执行原 JS。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from ..database import PROJECT_ROOT, SessionLocal
|
||||
from ..models import Project
|
||||
|
||||
# 上传根目录(与 routers/project.py 解析方式一致,可通过 .env 的 UPLOADS_DIR 覆盖)
|
||||
UPLOADS_ROOT = Path(os.getenv("UPLOADS_DIR") or str(PROJECT_ROOT / "uploads"))
|
||||
DEMOS_DIR = UPLOADS_ROOT / "demos"
|
||||
|
||||
# Javy 编译工具路径(可通过 .env 的 JAVY_PATH 覆盖)
|
||||
JAVY_PATH = os.getenv("JAVY_PATH") or "/usr/local/bin/javy"
|
||||
|
||||
# 功能总开关:false 时接口直接返回“未启用”,便于临时关闭
|
||||
WASM_COMPILE_ENABLED = os.getenv("WASM_COMPILE_ENABLED", "true").strip().lower() in (
|
||||
"1", "true", "yes", "on",
|
||||
)
|
||||
|
||||
# 单个 JS 文件大小上限(MB):防止超大文件把内存/磁盘撑爆
|
||||
WASM_MAX_JS_SIZE_MB = float(os.getenv("WASM_MAX_JS_SIZE_MB") or "3")
|
||||
|
||||
# 压缩包打包成单个 wasm 时,全部 JS 的总量上限(MB)
|
||||
WASM_BUNDLE_MAX_MB = float(os.getenv("WASM_BUNDLE_MAX_SIZE_MB") or "10")
|
||||
|
||||
# 单文件编译超时(秒):防止 javy 卡死拖慢接口
|
||||
WASM_TIMEOUT_SECONDS = int(os.getenv("WASM_TIMEOUT_SECONDS") or "60")
|
||||
|
||||
# 编译时跳过的目录(避免把编译产物/第三方依赖再编译一遍)
|
||||
_SKIP_DIRS = {"wasm", "node_modules", ".git", "__pycache__"}
|
||||
|
||||
|
||||
def javy_version() -> str:
|
||||
"""返回 Javy 版本号;工具未安装时返回空字符串。"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[JAVY_PATH, "--version"], capture_output=True, text=True, timeout=10,
|
||||
)
|
||||
return (result.stdout or result.stderr).strip()
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return ""
|
||||
|
||||
|
||||
def _resolve_zip_path(download_url: str) -> Path:
|
||||
"""把数据库里的下载地址(/uploads/project/xxx.zip)解析为服务器文件路径。"""
|
||||
if not download_url:
|
||||
return Path()
|
||||
relative = download_url.lstrip("/")
|
||||
if relative.startswith("uploads/"):
|
||||
relative = relative[len("uploads/"):]
|
||||
return UPLOADS_ROOT / relative
|
||||
|
||||
|
||||
def find_js_files(project_dir: Path):
|
||||
"""收集项目目录中的 .js 文件(跳过 wasm/ 等产物目录),按路径排序。"""
|
||||
files = []
|
||||
for path in sorted(project_dir.rglob("*.js")):
|
||||
rel = path.relative_to(project_dir)
|
||||
if any(part in _SKIP_DIRS for part in rel.parts):
|
||||
continue
|
||||
files.append(path)
|
||||
return files
|
||||
|
||||
|
||||
def _wasm_output_path(project_dir: Path, src: Path) -> Path:
|
||||
"""生成 wasm 输出路径:wasm/<去斜杠相对路径>.wasm,避免子目录重名冲突。"""
|
||||
rel = src.relative_to(project_dir)
|
||||
if len(rel.parts) > 1:
|
||||
slug = "_".join(rel.parts[:-1]) + "_" + rel.stem
|
||||
else:
|
||||
slug = rel.stem
|
||||
return project_dir / "wasm" / f"{slug}.wasm"
|
||||
|
||||
|
||||
def compile_one(src: Path, out: Path) -> dict:
|
||||
"""调用 javy 编译单个 JS 文件,返回该文件的编译结果记录。"""
|
||||
item = {
|
||||
"file": src.name,
|
||||
"status": "failed",
|
||||
"message": "",
|
||||
"js_size": src.stat().st_size,
|
||||
"wasm_size": 0,
|
||||
"compile_ms": 0,
|
||||
"output": None,
|
||||
}
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
start = time.monotonic()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[JAVY_PATH, "build", str(src), "-o", str(out)],
|
||||
capture_output=True, text=True, timeout=WASM_TIMEOUT_SECONDS,
|
||||
)
|
||||
item["compile_ms"] = int((time.monotonic() - start) * 1000)
|
||||
if result.returncode != 0:
|
||||
item["message"] = (result.stderr or result.stdout or "编译失败").strip()[:300]
|
||||
return item
|
||||
if out.is_file():
|
||||
item["status"] = "ok"
|
||||
item["wasm_size"] = out.stat().st_size
|
||||
item["output"] = str(out.relative_to(UPLOADS_ROOT)).replace("\\", "/")
|
||||
except subprocess.TimeoutExpired:
|
||||
item["message"] = f"编译超时(超过 {WASM_TIMEOUT_SECONDS} 秒)"
|
||||
except OSError as exc:
|
||||
item["message"] = f"无法运行 Javy:{exc}"
|
||||
return item
|
||||
|
||||
|
||||
def compile_zip_bundle(zip_path: Path) -> dict:
|
||||
"""把压缩包里的全部 JS 合并打包为单个 wasm(与 zip 同目录、同名 .wasm)。
|
||||
|
||||
说明:zip 不是 JS,不能直接编译;这里把 zip 内的所有 .js 按文件名排序
|
||||
拼接成一个临时 bundle.js,再交给 Javy 编译,产物即“压缩包的 wasm 版本”。
|
||||
"""
|
||||
item = {
|
||||
"file": zip_path.name,
|
||||
"status": "skipped",
|
||||
"message": "",
|
||||
"js_size": 0,
|
||||
"wasm_size": 0,
|
||||
"compile_ms": 0,
|
||||
"output": None,
|
||||
"source_zip": f"project/{zip_path.name}",
|
||||
}
|
||||
if not zip_path.is_file():
|
||||
item["message"] = "下载压缩包不存在,跳过"
|
||||
return item
|
||||
|
||||
max_file_bytes = int(WASM_MAX_JS_SIZE_MB * 1024 * 1024)
|
||||
max_total_bytes = int(WASM_BUNDLE_MAX_MB * 1024 * 1024)
|
||||
total_bytes = 0
|
||||
parts = []
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
name = info.filename.replace("\\", "/")
|
||||
if not name.lower().endswith(".js"):
|
||||
continue
|
||||
if name.startswith("__MACOSX/") or any(seg in _SKIP_DIRS for seg in name.split("/")):
|
||||
continue
|
||||
if info.file_size > max_file_bytes:
|
||||
item["message"] = f"zip 内含超限文件 {name}(超过 {WASM_MAX_JS_SIZE_MB:g}MB),跳过打包"
|
||||
return item
|
||||
total_bytes += info.file_size
|
||||
if total_bytes > max_total_bytes:
|
||||
item["message"] = f"zip 内 JS 总量超过 {WASM_BUNDLE_MAX_MB:g}MB,跳过打包"
|
||||
return item
|
||||
parts.append((name, zf.read(info)))
|
||||
except zipfile.BadZipFile:
|
||||
item["message"] = "压缩包损坏,跳过"
|
||||
return item
|
||||
|
||||
if not parts:
|
||||
item["message"] = "压缩包内没有 JS 文件,跳过"
|
||||
return item
|
||||
|
||||
# 按文件名排序后合并(保持确定性;浏览器脚本间依赖全局变量,字母序通常可用)
|
||||
parts.sort(key=lambda p: p[0])
|
||||
bundle_lines = []
|
||||
for name, data in parts:
|
||||
bundle_lines.append(f"// ===== {name} =====")
|
||||
bundle_lines.append(data.decode("utf-8", errors="replace"))
|
||||
bundle_js = "\n".join(bundle_lines)
|
||||
|
||||
# 写临时 bundle.js 编译,产物输出到 zip 同目录:{zip文件名}.wasm
|
||||
out = zip_path.with_suffix(".wasm")
|
||||
with tempfile.NamedTemporaryFile(
|
||||
"w", suffix=".js", encoding="utf-8", delete=False,
|
||||
) as tmp:
|
||||
tmp.write(bundle_js)
|
||||
tmp_path = Path(tmp.name)
|
||||
try:
|
||||
result = compile_one(tmp_path, out)
|
||||
item["status"] = result["status"]
|
||||
item["message"] = result["message"]
|
||||
item["js_size"] = total_bytes
|
||||
item["wasm_size"] = result["wasm_size"]
|
||||
item["compile_ms"] = result["compile_ms"]
|
||||
item["output"] = result["output"]
|
||||
if result["status"] == "ok":
|
||||
item["file"] = f"{zip_path.name}(全部 JS 打包为单个 wasm)"
|
||||
finally:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
return item
|
||||
|
||||
|
||||
def compile_project(project_id: int) -> dict:
|
||||
"""编译指定项目(演示目录 JS + 下载压缩包打包),返回完整报告。"""
|
||||
from fastapi import HTTPException, status # 局部导入,避免循环依赖
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
project = db.get(Project, project_id)
|
||||
finally:
|
||||
db.close()
|
||||
if project is None:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在")
|
||||
if not WASM_COMPILE_ENABLED:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="WASM 预编译功能未启用(WASM_COMPILE_ENABLED=false)",
|
||||
)
|
||||
if not (shutil.which(JAVY_PATH) or Path(JAVY_PATH).is_file()):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="服务器未安装 Javy 编译工具,请先执行 deploy/install_javy.sh",
|
||||
)
|
||||
|
||||
project_dir = DEMOS_DIR / str(project_id)
|
||||
items = []
|
||||
if project_dir.is_dir():
|
||||
files = find_js_files(project_dir)
|
||||
max_bytes = int(WASM_MAX_JS_SIZE_MB * 1024 * 1024)
|
||||
for src in files:
|
||||
if src.stat().st_size > max_bytes:
|
||||
items.append({
|
||||
"file": src.name,
|
||||
"status": "skipped",
|
||||
"message": f"文件超过 {WASM_MAX_JS_SIZE_MB:g}MB 上限",
|
||||
"js_size": src.stat().st_size,
|
||||
"wasm_size": 0,
|
||||
"compile_ms": 0,
|
||||
"output": None,
|
||||
})
|
||||
continue
|
||||
out = _wasm_output_path(project_dir, src)
|
||||
items.append(compile_one(src, out))
|
||||
else:
|
||||
items.append({
|
||||
"file": "(无在线演示目录)",
|
||||
"status": "skipped",
|
||||
"message": "该项目没有在线演示目录,仅尝试打包下载压缩包",
|
||||
"js_size": 0,
|
||||
"wasm_size": 0,
|
||||
"compile_ms": 0,
|
||||
"output": None,
|
||||
})
|
||||
|
||||
zip_bundle = compile_zip_bundle(_resolve_zip_path(project.download_url))
|
||||
|
||||
ok_items = [i for i in items if i["status"] == "ok"]
|
||||
report = {
|
||||
"project_id": project_id,
|
||||
"created_time": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"),
|
||||
"javy_version": javy_version(),
|
||||
"summary": {
|
||||
"js_files": len(items),
|
||||
"success": len(ok_items),
|
||||
"failed": sum(1 for i in items if i["status"] == "failed"),
|
||||
"skipped": sum(1 for i in items if i["status"] == "skipped"),
|
||||
"total_js_bytes": sum(i["js_size"] for i in items),
|
||||
"total_wasm_bytes": sum(i["wasm_size"] for i in ok_items),
|
||||
"zip_bundle": zip_bundle["status"],
|
||||
"zip_bundle_wasm_bytes": zip_bundle["wasm_size"],
|
||||
},
|
||||
"items": items,
|
||||
"zip_bundle": zip_bundle,
|
||||
}
|
||||
if project_dir.is_dir():
|
||||
report_dir = project_dir / "wasm"
|
||||
report_dir.mkdir(parents=True, exist_ok=True)
|
||||
(report_dir / "report.json").write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8",
|
||||
)
|
||||
return report
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""命令行入口:python -m backend.services.wasm_builder <项目ID>。"""
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="把项目 JS 预编译为 WASM")
|
||||
parser.add_argument("project_id", type=int, help="项目 ID")
|
||||
args = parser.parse_args()
|
||||
try:
|
||||
report = compile_project(args.project_id)
|
||||
except Exception as exc: # noqa: BLE001 - 命令行场景统一打印错误原因
|
||||
print(f"编译失败:{getattr(exc, 'detail', exc)}")
|
||||
sys.exit(1)
|
||||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user