413 lines
16 KiB
Python
413 lines
16 KiB
Python
"""
|
||
项目路由(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="项目已删除")
|