Initial commit: MyBlog full stack blog
This commit is contained in:
@@ -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="")
|
||||
Reference in New Issue
Block a user