Initial commit: MyBlog full stack blog

This commit is contained in:
2026-08-22 22:28:41 +08:00
commit c61193f2af
64 changed files with 7290 additions and 0 deletions
+34
View File
@@ -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)
+307
View File
@@ -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="删除成功")
+127
View File
@@ -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="获取成功")
+41
View File
@@ -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)
+161
View File
@@ -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="验证码校验通过",
)
+191
View File
@@ -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="已拒绝好友申请",
)
+264
View File
@@ -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 APIHMAC-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="白名单更新成功")
+85
View File
@@ -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="获取成功")
+70
View File
@@ -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="密码重置成功,请使用新密码登录")
+412
View File
@@ -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="项目已删除")
+193
View File
@@ -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-2003OLE 复合文档魔数
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="文档上传成功")
+199
View File
@@ -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="资料已更新")
+48
View File
@@ -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="")