Files
guzhujushiBlog/backend/routers/project.py
T

413 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
项目路由(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="项目已删除")